merge from open-source master
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 8f967d2..d7de1fb 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -7,20 +7,50 @@
     <uses-permission android:name="android.permission.GET_ACCOUNTS" />
     <uses-permission android:name="android.permission.READ_SYNC_STATS" />
     <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.USE_CREDENTIALS" />
     <uses-permission android:name="com.google.android.googleapps.permission.GOOGLE_AUTH" />
     <uses-permission android:name="com.google.android.googleapps.permission.GOOGLE_AUTH.cp" />
     <uses-permission android:name="android.permission.SUBSCRIBED_FEEDS_READ" />
     <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">
-            <path-permission android:path="/people/search_suggest_query"
+        android:label="@string/app_label"
+        android:icon="@drawable/app_icon">
+
+        <provider android:name="ContactsProvider" 
+            android:authorities="contacts"
+            android:syncable="false" android:multiprocess="false"
+            android:readPermission="android.permission.READ_CONTACTS"
+            android:writePermission="android.permission.WRITE_CONTACTS">
+            <path-permission
+                    android:path="/people/search_suggest_query"
                     android:readPermission="android.permission.GLOBAL_SEARCH" />
         </provider>
+
+        <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">
+            <path-permission
+                    android:path="/contacts/search_suggest_query"
+                    android:readPermission="android.permission.GLOBAL_SEARCH" />
+        </provider>
+
+        <provider android:name="CallLogProvider"
+            android:authorities="call_log"
+            android:syncable="false" android:multiprocess="false"
+            android:readPermission="android.permission.READ_CONTACTS"
+            android:writePermission="android.permission.WRITE_CONTACTS">
+        </provider>
+
+        <!-- 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/CallLogProvider.java b/src/com/android/providers/contacts/CallLogProvider.java
new file mode 100644
index 0000000..fbda970
--- /dev/null
+++ b/src/com/android/providers/contacts/CallLogProvider.java
@@ -0,0 +1,190 @@
+/*
+ * 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.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.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.provider.CallLog;
+import android.provider.CallLog.Calls;
+
+import java.util.HashMap;
+
+/**
+ * Call log content provider.
+ */
+public class CallLogProvider extends ContentProvider {
+
+    private static final int CALLS = 1;
+
+    private static final int CALLS_ID = 2;
+
+    private static final int CALLS_FILTER = 3;
+
+    private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+    static {
+        sURIMatcher.addURI(CallLog.AUTHORITY, "calls", CALLS);
+        sURIMatcher.addURI(CallLog.AUTHORITY, "calls/#", CALLS_ID);
+        sURIMatcher.addURI(CallLog.AUTHORITY, "calls/filter/*", CALLS_FILTER);
+    }
+
+    private static final HashMap<String, String> sCallsProjectionMap;
+    static {
+
+        // Calls projection map
+        sCallsProjectionMap = new HashMap<String, String>();
+        sCallsProjectionMap.put(Calls._ID, Calls._ID);
+        sCallsProjectionMap.put(Calls.NUMBER, Calls.NUMBER);
+        sCallsProjectionMap.put(Calls.DATE, Calls.DATE);
+        sCallsProjectionMap.put(Calls.DURATION, Calls.DURATION);
+        sCallsProjectionMap.put(Calls.TYPE, Calls.TYPE);
+        sCallsProjectionMap.put(Calls.NEW, Calls.NEW);
+        sCallsProjectionMap.put(Calls.CACHED_NAME, Calls.CACHED_NAME);
+        sCallsProjectionMap.put(Calls.CACHED_NUMBER_TYPE, Calls.CACHED_NUMBER_TYPE);
+        sCallsProjectionMap.put(Calls.CACHED_NUMBER_LABEL, Calls.CACHED_NUMBER_LABEL);
+    }
+
+    private OpenHelper mOpenHelper;
+    private DatabaseUtils.InsertHelper mCallsInserter;
+
+    @Override
+    public boolean onCreate() {
+        final Context context = getContext();
+
+        mOpenHelper = getOpenHelper(context);
+        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        mCallsInserter = new DatabaseUtils.InsertHelper(db, Tables.CALLS);
+
+        return true;
+    }
+
+    /* Visible for testing */
+    protected OpenHelper getOpenHelper(final Context context) {
+        return OpenHelper.getInstance(context);
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder) {
+        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        int match = sURIMatcher.match(uri);
+        switch (match) {
+            case CALLS: {
+                qb.setTables("calls");
+                qb.setProjectionMap(sCallsProjectionMap);
+                break;
+            }
+
+            case CALLS_ID: {
+                qb.setTables("calls");
+                qb.setProjectionMap(sCallsProjectionMap);
+                qb.appendWhere("calls._id=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+            }
+
+            case CALLS_FILTER: {
+                qb.setTables("calls");
+                qb.setProjectionMap(sCallsProjectionMap);
+                String phoneNumber = uri.getPathSegments().get(2);
+                qb.appendWhere("PHONE_NUMBERS_EQUAL(number, ");
+                qb.appendWhereEscapeString(phoneNumber);
+                qb.appendWhere(")");
+                break;
+            }
+
+            default:
+                throw new IllegalArgumentException("Unknown URL " + uri);
+        }
+
+        final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+        Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder, null);
+        if (c != null) {
+            c.setNotificationUri(getContext().getContentResolver(), CallLog.CONTENT_URI);
+        }
+        return c;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        int match = sURIMatcher.match(uri);
+        switch (match) {
+            case CALLS:
+                return Calls.CONTENT_TYPE;
+            case CALLS_ID:
+                return Calls.CONTENT_ITEM_TYPE;
+            case CALLS_FILTER:
+                return Calls.CONTENT_TYPE;
+            default:
+                throw new IllegalArgumentException("Unknown URI: " + uri);
+        }
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        long rowId = mCallsInserter.insert(values);
+        if (rowId > 0) {
+            return ContentUris.withAppendedId(uri, rowId);
+        }
+        return null;
+    }
+
+    @Override
+    public int update(Uri url, ContentValues values, String selection, String[] selectionArgs) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        String where;
+        final int matchedUriId = sURIMatcher.match(url);
+        switch (matchedUriId) {
+            case CALLS:
+                where = selection;
+                break;
+
+            case CALLS_ID:
+                where = DatabaseUtils.concatenateWhere(selection, Calls._ID + "="
+                        + url.getPathSegments().get(1));
+                break;
+
+            default:
+                throw new UnsupportedOperationException("Cannot update URL: " + url);
+        }
+
+        return db.update(Tables.CALLS, values, where, selectionArgs);
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        final int matchedUriId = sURIMatcher.match(uri);
+        switch (matchedUriId) {
+            case CALLS:
+                return db.delete(Tables.CALLS, selection, selectionArgs);
+
+            default:
+                throw new UnsupportedOperationException("Cannot delete that URL: " + uri);
+        }
+    }
+}
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..ad70640
--- /dev/null
+++ b/src/com/android/providers/contacts/ContactAggregator.java
@@ -0,0 +1,1226 @@
+/*
+ * 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.AggregationExceptionColumns;
+import com.android.providers.contacts.OpenHelper.Clauses;
+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.RawContactsColumns;
+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.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.CommonDataKinds;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+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, RawContacts.CONTACT_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_CONTACT_ID = 2;
+
+    private static final String[] NAME_LOOKUP_COLUMNS = new String[] {
+            RawContacts.CONTACT_ID, NameLookupColumns.NORMALIZED_NAME, NameLookupColumns.NAME_TYPE
+    };
+
+    private static final int COL_NAME_LOOKUP_CONTACT_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.RAW_CONTACT_ID1,
+            "raw_contacts1." + RawContacts.CONTACT_ID,
+            "raw_contacts2." + RawContacts.CONTACT_ID
+    };
+
+    private static final int COL_TYPE = 0;
+    private static final int COL_RAW_CONTACT_ID1 = 1;
+    private static final int COL_CONTACT_ID1 = 2;
+    private static final int COL_CONTACT_ID2 = 3;
+
+    private static final String[] CONTACT_ID_COLUMN = new String[] { RawContacts._ID };
+
+    private static final String[] CONTACT_OPTIONS_COLUMNS = new String[] {
+            RawContacts.CUSTOM_RINGTONE,
+            RawContacts.SEND_TO_VOICEMAIL,
+            RawContacts.LAST_TIME_CONTACTED,
+            RawContacts.TIMES_CONTACTED,
+            RawContacts.STARRED,
+    };
+
+    private static final int COL_CUSTOM_RINGTONE = 0;
+    private static final int COL_SEND_TO_VOICEMAIL = 1;
+    private static final int COL_LAST_TIME_CONTACTED = 2;
+    private static final int COL_TIMES_CONTACTED = 3;
+    private static final int COL_STARRED = 4;
+
+    private static final String[] CONTACT_ID_COLUMNS = new String[]{ RawContacts.CONTACT_ID };
+    private static final int COL_CONTACT_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;
+
+    /**
+     * 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 RawContacts#CONTACT_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.RAW_CONTACTS, new String[]{RawContacts._ID},
+                RawContacts.CONTACT_ID + " IS NULL AND "
+                        + RawContacts.AGGREGATION_MODE + "=" + RawContacts.AGGREGATION_MODE_DEFAULT,
+                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 rawContactId) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        db.beginTransaction();
+        try {
+            aggregateContact(db, rawContactId);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    /**
+     * Synchronously aggregate the specified contact assuming an open transaction.
+     */
+    public void aggregateContact(SQLiteDatabase db, long rawContactId) {
+        MatchCandidateList candidates = new MatchCandidateList();
+        ContactMatcher matcher = new ContactMatcher();
+        ContentValues values = new ContentValues();
+        aggregateContact(db, rawContactId, candidates, matcher, values);
+    }
+
+    /**
+     * Marks the specified contact for (re)aggregation.
+     *
+     * @param rawContactId contact ID that needs to be (re)aggregated
+     * @return The contact aggregation mode:
+     *         {@link RawContacts#AGGREGATION_MODE_DEFAULT},
+     *         {@link RawContacts#AGGREGATION_MODE_IMMEDIATE} or
+     *         {@link RawContacts#AGGREGATION_MODE_DISABLED}.
+     */
+    public int markContactForAggregation(long rawContactId) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        int aggregationMode = mOpenHelper.getAggregationMode(rawContactId);
+        if (aggregationMode == RawContacts.AGGREGATION_MODE_DISABLED) {
+            return aggregationMode;
+        }
+
+        long contactId = mOpenHelper.getContactId(rawContactId);
+        if (contactId == 0) {
+            // Not aggregated
+            return aggregationMode;
+        }
+
+        mOpenHelper.removeContactIfSingleton(rawContactId);
+
+        // TODO compiled statements
+
+        // Clear out data used for aggregation - we will recreate it during aggregation
+        db.execSQL("DELETE FROM " + Tables.NAME_LOOKUP + " WHERE "
+                + NameLookupColumns.RAW_CONTACT_ID + "=" + rawContactId);
+
+        // Clear out the contact ID field on the contact
+        ContentValues values = new ContentValues();
+        values.putNull(RawContacts.CONTACT_ID);
+        db.update(Tables.RAW_CONTACTS, values, RawContacts._ID + "=" + rawContactId, null);
+
+        return aggregationMode;
+    }
+
+    public void updateAggregateData(long contactId) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        final ContentValues values = new ContentValues();
+        updateAggregateData(db, contactId, values);
+    }
+
+    /**
+     * Given a specific raw contact, finds all matching aggregate contacts and chooses the one
+     * with the highest match score.  If no such contact is found, creates a new contact.
+     */
+    /* package */ synchronized void aggregateContact(SQLiteDatabase db, long rawContactId,
+            MatchCandidateList candidates, ContactMatcher matcher, ContentValues values) {
+        candidates.clear();
+        matcher.clear();
+
+        long contactId = pickBestMatchBasedOnExceptions(db, rawContactId, matcher);
+        if (contactId == -1) {
+            contactId = pickBestMatchBasedOnData(db, rawContactId, candidates, matcher);
+        }
+
+        boolean newAgg = false;
+
+        if (contactId == -1) {
+            newAgg = true;
+            ContentValues contactValues = new ContentValues();
+            contactValues.put(Contacts.DISPLAY_NAME, "");
+            contactId = db.insert(Tables.CONTACTS, Contacts.DISPLAY_NAME, contactValues);
+        }
+
+        updateContactAggregationData(db, rawContactId, candidates, values);
+        mOpenHelper.setContactId(rawContactId, contactId);
+
+        updateAggregateData(db, contactId, values);
+        updatePrimaries(db, contactId, rawContactId, newAgg);
+        mOpenHelper.updateContactVisible(contactId);
+
+    }
+
+    /**
+     * Computes match scores based on exceptions entered by the user: always match and never match.
+     * Returns the aggregate contact with the always match exception if any.
+     */
+    private long pickBestMatchBasedOnExceptions(SQLiteDatabase db, long rawContactId,
+            ContactMatcher matcher) {
+        final Cursor c = db.query(Tables.AGGREGATION_EXCEPTIONS_JOIN_RAW_CONTACTS_TWICE,
+                AGGREGATE_EXCEPTION_JOIN_CONTACT_TWICE_COLUMNS,
+                AggregationExceptionColumns.RAW_CONTACT_ID1 + "=" + rawContactId
+                        + " OR " + AggregationExceptionColumns.RAW_CONTACT_ID2 + "=" + rawContactId,
+                null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                int type = c.getInt(COL_TYPE);
+                long rawContactId1 = c.getLong(COL_RAW_CONTACT_ID1);
+                long contactId = -1;
+                if (rawContactId == rawContactId1) {
+                    if (!c.isNull(COL_CONTACT_ID2)) {
+                        contactId = c.getLong(COL_CONTACT_ID2);
+                    }
+                } else {
+                    if (!c.isNull(COL_CONTACT_ID1)) {
+                        contactId = c.getLong(COL_CONTACT_ID1);
+                    }
+                }
+                if (contactId != -1) {
+                    if (type == AggregationExceptions.TYPE_KEEP_IN) {
+                        return contactId;
+                    } else {
+                        matcher.keepOut(contactId);
+                    }
+                }
+            }
+        } finally {
+            c.close();
+        }
+
+        return -1;
+    }
+
+    /**
+     * Picks the best matching contact 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 rawContactId,
+            MatchCandidateList candidates, ContactMatcher matcher) {
+
+        updateMatchScoresBasedOnDataMatches(db, rawContactId, 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 contact based on secondary data matches.  The method loads
+     * structured names for all candidate contacts and recomputes match scores using approximate
+     * matching.
+     */
+    private long pickBestMatchBasedOnSecondaryData(SQLiteDatabase db,
+            MatchCandidateList candidates, ContactMatcher matcher) {
+        List<Long> secondaryContactIds = matcher.prepareSecondaryMatchCandidates(
+                ContactMatcher.SCORE_THRESHOLD_PRIMARY);
+        if (secondaryContactIds == null) {
+            return -1;
+        }
+
+        StringBuilder selection = new StringBuilder();
+        selection.append(RawContacts.CONTACT_ID).append(" IN (");
+        for (int i = 0; i < secondaryContactIds.size(); i++) {
+            if (i != 0) {
+                selection.append(',');
+            }
+            selection.append(secondaryContactIds.get(i));
+        }
+        selection.append(") AND " + MimetypesColumns.MIMETYPE + "='"
+                + StructuredName.CONTENT_ITEM_TYPE + "'");
+
+        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPE_RAW_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 contactId = c.getLong(COL_DATA_CONTACT_CONTACT_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(contactId,
+                                    nameCandidate.mLookupType, nameCandidate.mName,
+                                    candidate.mLookupType, candidate.mName, true);
+                        }
+                    }
+                }
+            }
+        } finally {
+            c.close();
+        }
+
+        return matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_SECONDARY);
+    }
+
+    /**
+     * Computes scores for contacts that have matching data rows.
+     */
+    private void updateMatchScoresBasedOnDataMatches(SQLiteDatabase db, long rawContactId,
+            int mode, MatchCandidateList candidates, ContactMatcher matcher) {
+
+        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPE_RAW_CONTACTS,
+                DATA_JOIN_MIMETYPE_COLUMNS,
+                Data.RAW_CONTACT_ID + "=" + rawContactId + " AND ("
+                        + 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(RawContacts.CONTACT_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 " + RawContacts.CONTACT_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_RAW_CONTACTS, NAME_LOOKUP_COLUMNS,
+                selection, null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                Long contactId = c.getLong(COL_NAME_LOOKUP_CONTACT_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(contactId, 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, CONTACT_ID_COLUMNS,
+                RawContacts.CONTACT_ID + " NOT NULL", null, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long contactId = c.getLong(COL_CONTACT_ID);
+                matcher.updateScoreWithPhoneNumberMatch(contactId);
+            }
+        } 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_RAW_CONTACTS, CONTACT_ID_COLUMNS,
+                Clauses.WHERE_EMAIL_MATCHES + " AND " + RawContacts.CONTACT_ID + " NOT NULL",
+                new String[]{address}, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long contactId = c.getLong(COL_CONTACT_ID);
+                matcher.updateScoreWithEmailMatch(contactId);
+            }
+        } 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_RAW_CONTACTS, CONTACT_ID_COLUMNS,
+                NameLookupColumns.NAME_TYPE + "=" + NameLookupType.NICKNAME + " AND "
+                        + NameLookupColumns.NORMALIZED_NAME + "='" + normalized + "' AND "
+                        + RawContacts.CONTACT_ID + " NOT NULL",
+                null, null, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long contactId = c.getLong(COL_CONTACT_ID);
+                matcher.updateScoreWithNicknameMatch(contactId);
+            }
+        } finally {
+            c.close();
+        }
+    }
+
+    /**
+     * Prepares the supplied contact for aggregation with other contacts by (re)computing
+     * match lookup keys.
+     */
+    private void updateContactAggregationData(SQLiteDatabase db, long rawContactId,
+            MatchCandidateList candidates, ContentValues values) {
+        candidates.clear();
+
+        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPES,
+                DATA_JOIN_MIMETYPE_COLUMNS,
+                DatabaseUtils.concatenateWhere(Data.RAW_CONTACT_ID + "=" + rawContactId,
+                        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(rawContactId, candidate.mLookupType, candidate.mName);
+        }
+    }
+
+    /**
+     * Updates aggregate-level data from constituent contacts.
+     */
+    private void updateAggregateData(final SQLiteDatabase db, long contactId,
+            final ContentValues values) {
+        updateDisplayName(db, contactId, values);
+        updateSendToVoicemailAndRingtone(db, contactId);
+        updatePhotoId(db, contactId, values);
+    }
+
+    /**
+     * Updates the contact record's {@link Contacts#DISPLAY_NAME} field. If none of the
+     * constituent raw contacts has a suitable name, leaves the aggregate contact record unchanged.
+     */
+    private void updateDisplayName(SQLiteDatabase db, long contactId, ContentValues values) {
+        String displayName = getBestDisplayName(db, contactId);
+
+        // 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(Contacts.DISPLAY_NAME, displayName);
+        db.update(Tables.CONTACTS, values, Contacts._ID + "=" + contactId, null);
+    }
+
+    private void updatePhotoId(SQLiteDatabase db, long contactId, ContentValues values) {
+        int photoId = choosePhotoId(db, contactId);
+
+        if (photoId == -1) {
+            return;
+        }
+
+        values.clear();
+        values.put(Contacts.PHOTO_ID, photoId);
+        db.update(Tables.CONTACTS, values, Contacts._ID + "=" + contactId, null);
+    }
+
+    /**
+     * Updates the various {@link ContactsColumns} primary values based on the
+     * newly joined {@link RawContacts} 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 rawContactId, 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.CONTACTS, Projections.PROJ_CONTACT_PRIMARIES,
+                    Contacts._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;
+        boolean candidateIsRestricted = false;
+
+        // Find primary data items from newly-joined contact, returning one
+        // candidate for each mimetype.
+        try {
+            cursor = db.query(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS,
+                    Projections.PROJ_DATA,
+                    Data.RAW_CONTACT_ID + "=" + rawContactId + " 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);
+
+                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 contact, and single child is restricted, then mark
+        // contact as being protected by package. Otherwise set as null if
+        // multiple under contact or not restricted.
+        values.put(ContactsColumns.SINGLE_IS_RESTRICTED, (newAgg && candidateIsRestricted) ? 1
+                : 0);
+
+        // 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(ContactsColumns.OPTIMAL_PRIMARY_PHONE_ID, candidatePhone);
+                values.put(ContactsColumns.OPTIMAL_PRIMARY_PHONE_IS_RESTRICTED,
+                        candidateIsRestricted ? 1 : 0);
+            }
+
+            // Also promote to unrestricted value, if none provided yet.
+            if (!hasFallbackPhone && !candidateIsRestricted) {
+                values.put(ContactsColumns.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(ContactsColumns.OPTIMAL_PRIMARY_EMAIL_ID, candidateEmail);
+                values.put(ContactsColumns.OPTIMAL_PRIMARY_EMAIL_IS_RESTRICTED,
+                        candidateIsRestricted ? 1 : 0);
+            }
+
+            // Also promote to unrestricted value, if none provided yet.
+            if (!hasFallbackEmail && !candidateIsRestricted) {
+                values.put(ContactsColumns.FALLBACK_PRIMARY_EMAIL_ID, candidateEmail);
+            }
+        }
+
+        // Only write updated contact values if we made changes.
+        if (values.size() > 0) {
+            Log.d(TAG, "some sort of promotion is going on: " + values.toString());
+            db.update(Tables.CONTACTS, values, Contacts._ID + "=" + aggId, null);
+        }
+
+    }
+
+    /**
+     * Computes display name for the given contact.  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 contactId) {
+        String bestDisplayName = null;
+
+        final Cursor c = db.query(Tables.RAW_CONTACTS, new String[] {RawContactsColumns.DISPLAY_NAME},
+                RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                String displayName = c.getString(0);
+                if (!TextUtils.isEmpty(displayName)) {
+                    if (bestDisplayName == null) {
+                        bestDisplayName = displayName;
+                    } else {
+                        if (NameNormalizer.compareComplexity(displayName, bestDisplayName) > 0) {
+                            bestDisplayName = displayName;
+                        }
+                    }
+                }
+            }
+        } finally {
+            c.close();
+        }
+        return bestDisplayName;
+    }
+
+    /**
+     * Iterates over the photos associated with contact defined by contactId, and chooses one
+     * to be associated with the contact. Initially this just chooses the first photo in a list
+     * sorted by account name.
+     */
+    private int choosePhotoId(SQLiteDatabase db, long contactId) {
+        int chosenPhotoId = -1;
+        String chosenAccount = null;
+
+        final Cursor c = db.query(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS,
+                new String[] {"data._id AS _id", RawContacts.ACCOUNT_NAME},
+                DatabaseUtils.concatenateWhere(RawContacts.CONTACT_ID + "=" + contactId,
+                        Data.MIMETYPE + "='" + Photo.CONTENT_ITEM_TYPE + "'"),
+                null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                int photoId = c.getInt(0);
+                String account = c.getString(1);
+                if (chosenAccount == null) {
+                    chosenAccount = account;
+                    chosenPhotoId = photoId;
+                } else {
+                    if (account.compareToIgnoreCase(chosenAccount) < 0 ) {
+                        chosenAccount = account;
+                        chosenPhotoId = photoId;
+                    }
+                }
+            }
+        } finally {
+            c.close();
+        }
+        return chosenPhotoId;
+    }
+
+    /**
+     * Updates the contact's send-to-voicemail and custom-ringtone options based on
+     * constituent contacts' options.
+     */
+    private void updateSendToVoicemailAndRingtone(SQLiteDatabase db, long contactId) {
+        int totalContactCount = 0;
+        int contactSendToVoicemail = 0;
+        String contactCustomRingtone = null;
+        long contactLastTimeContacted = 0;
+        int contactTimesContacted = 0;
+        boolean contactStarred = false;
+
+        final Cursor c = db.query(Tables.RAW_CONTACTS, CONTACT_OPTIONS_COLUMNS,
+                RawContacts.CONTACT_ID + "=" + contactId, 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) {
+                        contactSendToVoicemail++;
+                    }
+                }
+
+                if (contactCustomRingtone == null && !c.isNull(COL_CUSTOM_RINGTONE)) {
+                    contactCustomRingtone = c.getString(COL_CUSTOM_RINGTONE);
+                }
+
+                long lastTimeContacted = c.getLong(COL_LAST_TIME_CONTACTED);
+                if (lastTimeContacted > contactLastTimeContacted) {
+                    contactLastTimeContacted = lastTimeContacted;
+                }
+
+                int timesContacted = c.getInt(COL_TIMES_CONTACTED);
+                if (timesContacted > contactTimesContacted) {
+                    contactTimesContacted = timesContacted;
+                }
+
+                contactStarred |= (c.getInt(COL_STARRED) != 0);
+            }
+        } finally {
+            c.close();
+        }
+
+        ContentValues values = new ContentValues(2);
+        values.put(Contacts.SEND_TO_VOICEMAIL, totalContactCount == contactSendToVoicemail);
+        values.put(Contacts.CUSTOM_RINGTONE, contactCustomRingtone);
+        values.put(Contacts.LAST_TIME_CONTACTED, contactLastTimeContacted);
+        values.put(Contacts.TIMES_CONTACTED, contactTimesContacted);
+        values.put(Contacts.STARRED, contactStarred);
+
+        db.update(Tables.CONTACTS, values, Contacts._ID + "=" + contactId, null);
+    }
+
+    /**
+     * Finds matching contacts and returns a cursor on those.
+     */
+    public Cursor queryAggregationSuggestions(long contactId, 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 = findMatchingContacts(db, contactId, maxSuggestions);
+            c = queryMatchingContacts(db, contactId, projection, projectionMap, bestMatches);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+        return c;
+    }
+
+    /**
+     * Loads contacts with specified IDs and returns them in the order of IDs in the
+     * supplied list.
+     */
+    private Cursor queryMatchingContacts(final SQLiteDatabase db, long contactId,
+            String[] projection, HashMap<String, String> projectionMap,
+            List<MatchScore> bestMatches) {
+
+        StringBuilder selection = new StringBuilder();
+        selection.append(Contacts._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.getContactId());
+        }
+        selection.append(")");
+
+        final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        qb.setTables(Tables.CONTACTS);
+        qb.setProjectionMap(projectionMap);
+
+        final Cursor cursor = qb.query(db, projection, selection.toString(), null, null, null,
+                Contacts._ID);
+
+        ArrayList<Long> sortedContactIds = new ArrayList<Long>(bestMatches.size());
+        for (MatchScore matchScore : bestMatches) {
+            sortedContactIds.add(matchScore.getContactId());
+        }
+
+        Collections.sort(sortedContactIds);
+
+        int[] positionMap = new int[bestMatches.size()];
+        for (int i = 0; i < positionMap.length; i++) {
+            long id = bestMatches.get(i).getContactId();
+            positionMap[i] = sortedContactIds.indexOf(id);
+        }
+
+        return new ReorderingCursorWrapper(cursor, positionMap);
+    }
+
+    /**
+     * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the
+     * descending order of match score.
+     */
+    private List<MatchScore> findMatchingContacts(final SQLiteDatabase db,
+            long contactId, int maxSuggestions) {
+
+        MatchCandidateList candidates = new MatchCandidateList();
+        ContactMatcher matcher = new ContactMatcher();
+
+        // Don't aggregate a contact with itself
+        matcher.keepOut(contactId);
+
+        final Cursor c = db.query(Tables.RAW_CONTACTS, CONTACT_ID_COLUMN,
+                RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long rawContactId = c.getLong(0);
+                updateMatchScoresBasedOnDataMatches(db, rawContactId, 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_CONTACT_PRIMARIES = new String[] {
+                ContactsColumns.OPTIMAL_PRIMARY_PHONE_ID,
+                ContactsColumns.FALLBACK_PRIMARY_PHONE_ID,
+                ContactsColumns.OPTIMAL_PRIMARY_EMAIL_ID,
+                ContactsColumns.FALLBACK_PRIMARY_EMAIL_ID,
+                ContactsColumns.SINGLE_IS_RESTRICTED,
+        };
+
+        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_IS_RESTRICTED = 4;
+
+        static final String[] PROJ_DATA = new String[] {
+                Tables.DATA + "." + Data._ID,
+                Data.MIMETYPE,
+                RawContacts.IS_RESTRICTED,
+        };
+
+        static final int COL_DATA_ID = 0;
+        static final int COL_DATA_MIMETYPE = 1;
+        static final int COL_IS_RESTRICTED = 2;
+
+        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..6ab98dd
--- /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 contact.  Used in an
+     * contactId - MatchScore map.
+     */
+    public static class MatchScore implements Comparable<MatchScore> {
+        private long mContactId;
+        private boolean mKeepIn;
+        private boolean mKeepOut;
+        private int mPrimaryScore;
+        private int mSecondaryScore;
+        private int mMatchCount;
+
+        public MatchScore(long contactId) {
+            this.mContactId = contactId;
+        }
+
+        public void reset(long contactId) {
+            this.mContactId = contactId;
+            mKeepIn = false;
+            mKeepOut = false;
+            mPrimaryScore = 0;
+            mSecondaryScore = 0;
+            mMatchCount = 0;
+        }
+
+        public long getContactId() {
+            return mContactId;
+        }
+
+        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 contacts 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 mContactId + ": " + 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 contactId) {
+        MatchScore matchingScore = mScores.get(contactId);
+        if (matchingScore == null) {
+            if (mScoreList.size() > mScoreCount) {
+                matchingScore = mScoreList.get(mScoreCount);
+                matchingScore.reset(contactId);
+            } else {
+                matchingScore = new MatchScore(contactId);
+                mScoreList.add(matchingScore);
+            }
+            mScoreCount++;
+            mScores.put(contactId, matchingScore);
+        }
+        return matchingScore;
+    }
+
+    /**
+     * Checks if there is a match and updates the overall score for the
+     * specified contact 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 contactId, int candidateNameType, String candidateName,
+            int nameType, String name, boolean approximate) {
+        int maxScore = getMaxScore(candidateNameType, nameType);
+        if (maxScore == 0) {
+            return;
+        }
+
+        if (candidateName.equals(name)) {
+            updatePrimaryScore(contactId, 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(contactId, score);
+    }
+
+    public void updateScoreWithPhoneNumberMatch(long contactId) {
+        updateSecondaryScore(contactId, PHONE_MATCH_SCORE);
+    }
+
+    public void updateScoreWithEmailMatch(long contactId) {
+        updateSecondaryScore(contactId, EMAIL_MATCH_SCORE);
+    }
+
+    public void updateScoreWithNicknameMatch(long contactId) {
+        updateSecondaryScore(contactId, NICKNAME_MATCH_SCORE);
+    }
+
+    private void updatePrimaryScore(long contactId, int score) {
+        getMatchingScore(contactId).updatePrimaryScore(score);
+    }
+
+    private void updateSecondaryScore(long contactId, int score) {
+        getMatchingScore(contactId).updateSecondaryScore(score);
+    }
+
+    public void keepIn(long contactId) {
+        getMatchingScore(contactId).keepIn();
+    }
+
+    public void keepOut(long contactId) {
+        getMatchingScore(contactId).keepOut();
+    }
+
+    public void clear() {
+        mScores.clear();
+        mScoreCount = 0;
+    }
+
+    /**
+     * Returns a list of IDs for contacts that are matched on secondary data elements
+     * (phone number, email address, nickname). We still need to obtain the approximate
+     * primary score for those contacts to determine if any of them should be contactd.
+     * <p>
+     * May return null.
+     */
+    public List<Long> prepareSecondaryMatchCandidates(int threshold) {
+        ArrayList<Long> contactIds = 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 (contactIds == null) {
+                    contactIds = new ArrayList<Long>();
+                }
+                contactIds.add(score.mContactId);
+                score.mPrimaryScore = NO_DATA_SCORE;
+            }
+        }
+        return contactIds;
+    }
+
+    /**
+     * Returns the contactId with the best match score over the specified threshold or -1
+     * if no such contact is found.
+     */
+    public long pickBestMatch(int threshold) {
+        long contactId = -1;
+        int maxScore = 0;
+        for (int i = 0; i < mScoreCount; i++) {
+            MatchScore score = mScoreList.get(i);
+            if (score.mKeepIn) {
+                return score.mContactId;
+            }
+
+            if (score.mKeepOut) {
+                continue;
+            }
+
+            int s = score.mPrimaryScore;
+            if (s == NO_DATA_SCORE) {
+                s = score.mSecondaryScore;
+            }
+
+            if (s >= threshold && s > maxScore) {
+                contactId = score.mContactId;
+                maxScore = s;
+            }
+        }
+        return contactId;
+    }
+
+    /**
+     * 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/ContactsProvider.java b/src/com/android/providers/contacts/ContactsProvider.java
index 777aa61..a89b6db 100644
--- a/src/com/android/providers/contacts/ContactsProvider.java
+++ b/src/com/android/providers/contacts/ContactsProvider.java
@@ -44,10 +44,8 @@
 import android.os.MemoryFile;
 import android.os.ParcelFileDescriptor;
 import android.provider.CallLog;
-import android.provider.Contacts;
-import android.provider.LiveFolders;
-import android.provider.SyncConstValue;
 import android.provider.CallLog.Calls;
+import android.provider.Contacts;
 import android.provider.Contacts.ContactMethods;
 import android.provider.Contacts.Extensions;
 import android.provider.Contacts.GroupMembership;
@@ -61,22 +59,24 @@
 import android.provider.Contacts.Photos;
 import android.provider.Contacts.Presence;
 import android.provider.Contacts.PresenceColumns;
+import android.provider.LiveFolders;
+import android.provider.SyncConstValue;
+import android.provider.Calendar;
 import android.telephony.PhoneNumberUtils;
 import android.text.TextUtils;
 import android.util.Config;
 import android.util.Log;
-
-import com.google.android.collect.Maps;
-import com.google.android.collect.Sets;
+import android.accounts.Account;
 
 import com.android.internal.database.ArrayListCursor;
+import com.google.android.collect.Maps;
+import com.google.android.collect.Sets;
 
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 
@@ -125,7 +125,7 @@
      * other methods (other than the DB layer) */
     private final ContentValues mValuesLocal = new ContentValues();
 
-    private String[] mAccounts = new String[0];
+    private Account[] mAccounts = new Account[0];
     private final Object mAccountsLock = new Object();
 
     private DatabaseUtils.InsertHelper mDeletedPeopleInserter;
@@ -134,7 +134,8 @@
     private int mIndexPeopleSyncTime;
     private int mIndexPeopleSyncVersion;
     private int mIndexPeopleSyncDirty;
-    private int mIndexPeopleSyncAccount;
+    private int mIndexPeopleSyncAccountName;
+    private int mIndexPeopleSyncAccountType;
     private int mIndexPeopleName;
     private int mIndexPeoplePhoneticName;
     private int mIndexPeopleNotes;
@@ -145,7 +146,8 @@
     private int mIndexPhotosSyncTime;
     private int mIndexPhotosSyncVersion;
     private int mIndexPhotosSyncDirty;
-    private int mIndexPhotosSyncAccount;
+    private int mIndexPhotosSyncAccountName;
+    private int mIndexPhotosSyncAccountType;
     private int mIndexPhotosExistsOnServer;
     private int mIndexPhotosSyncError;
     private DatabaseUtils.InsertHelper mContactMethodsInserter;
@@ -169,7 +171,8 @@
     private int mIndexExtensionsValue;
     private DatabaseUtils.InsertHelper mGroupMembershipInserter;
     private int mIndexGroupMembershipPersonId;
-    private int mIndexGroupMembershipGroupSyncAccount;
+    private int mIndexGroupMembershipGroupSyncAccountName;
+    private int mIndexGroupMembershipGroupSyncAccountType;
     private int mIndexGroupMembershipGroupSyncId;
     private DatabaseUtils.InsertHelper mCallsInserter;
     private DatabaseUtils.InsertHelper mPhonesInserter;
@@ -180,28 +183,8 @@
     private int mIndexPhonesNumberKey;
     private int mIndexPhonesIsPrimary;
 
-    private static HashMap<String, String> mSearchSuggestionsProjectionMap;
-    private static String mSearchSuggestionLanguage;
-    
     public ContactsProvider() {
         super(DATABASE_NAME, DATABASE_VERSION, Contacts.CONTENT_URI);
-        mSearchSuggestionLanguage = Locale.getDefault().getLanguage();
-        // Search suggestions projection map
-        mSearchSuggestionsProjectionMap = new HashMap<String, String>();
-        updateSuggestColumnTexts();
-        mSearchSuggestionsProjectionMap.put(SearchManager.SUGGEST_COLUMN_ICON_1,
-                "(CASE WHEN " + Photos.DATA + " IS NOT NULL"
-                + " THEN '" + People.CONTENT_URI + "/' || people._id ||"
-                        + " '/" + Photos.CONTENT_DIRECTORY + "/data'"
-                + " ELSE " + com.android.internal.R.drawable.ic_contact_picture
-                + " END) AS " + SearchManager.SUGGEST_COLUMN_ICON_1);
-        mSearchSuggestionsProjectionMap.put(SearchManager.SUGGEST_COLUMN_ICON_2,
-                PRESENCE_ICON_SQL + " AS " + SearchManager.SUGGEST_COLUMN_ICON_2);
-        mSearchSuggestionsProjectionMap.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID,
-                "people._id AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID);
-        mSearchSuggestionsProjectionMap.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
-                "people._id AS " + SearchManager.SUGGEST_COLUMN_SHORTCUT_ID);
-        mSearchSuggestionsProjectionMap.put(People._ID, "people._id AS " + People._ID);
     }
 
     @Override
@@ -223,7 +206,8 @@
         mIndexPeopleSyncTime = mPeopleInserter.getColumnIndex(People._SYNC_TIME);
         mIndexPeopleSyncVersion = mPeopleInserter.getColumnIndex(People._SYNC_VERSION);
         mIndexPeopleSyncDirty = mPeopleInserter.getColumnIndex(People._SYNC_DIRTY);
-        mIndexPeopleSyncAccount = mPeopleInserter.getColumnIndex(People._SYNC_ACCOUNT);
+        mIndexPeopleSyncAccountName = mPeopleInserter.getColumnIndex(People._SYNC_ACCOUNT);
+        mIndexPeopleSyncAccountType = mPeopleInserter.getColumnIndex(People._SYNC_ACCOUNT_TYPE);
         mIndexPeopleName = mPeopleInserter.getColumnIndex(People.NAME);
         mIndexPeoplePhoneticName = mPeopleInserter.getColumnIndex(People.PHONETIC_NAME);
         mIndexPeopleNotes = mPeopleInserter.getColumnIndex(People.NOTES);
@@ -236,26 +220,32 @@
         mIndexPhotosSyncTime = mPhotosInserter.getColumnIndex(Photos._SYNC_TIME);
         mIndexPhotosSyncVersion = mPhotosInserter.getColumnIndex(Photos._SYNC_VERSION);
         mIndexPhotosSyncDirty = mPhotosInserter.getColumnIndex(Photos._SYNC_DIRTY);
-        mIndexPhotosSyncAccount = mPhotosInserter.getColumnIndex(Photos._SYNC_ACCOUNT);
+        mIndexPhotosSyncAccountName = mPhotosInserter.getColumnIndex(Photos._SYNC_ACCOUNT);
+        mIndexPhotosSyncAccountType = mPhotosInserter.getColumnIndex(Photos._SYNC_ACCOUNT_TYPE);
         mIndexPhotosSyncError = mPhotosInserter.getColumnIndex(Photos.SYNC_ERROR);
         mIndexPhotosExistsOnServer = mPhotosInserter.getColumnIndex(Photos.EXISTS_ON_SERVER);
 
         mContactMethodsInserter = new DatabaseUtils.InsertHelper(db, sContactMethodsTable);
-        mIndexContactMethodsPersonId = mContactMethodsInserter.getColumnIndex(ContactMethods.PERSON_ID);
+        mIndexContactMethodsPersonId =
+                mContactMethodsInserter.getColumnIndex(ContactMethods.PERSON_ID);
         mIndexContactMethodsLabel = mContactMethodsInserter.getColumnIndex(ContactMethods.LABEL);
         mIndexContactMethodsKind = mContactMethodsInserter.getColumnIndex(ContactMethods.KIND);
         mIndexContactMethodsType = mContactMethodsInserter.getColumnIndex(ContactMethods.TYPE);
         mIndexContactMethodsData = mContactMethodsInserter.getColumnIndex(ContactMethods.DATA);
-        mIndexContactMethodsAuxData = mContactMethodsInserter.getColumnIndex(ContactMethods.AUX_DATA);
-        mIndexContactMethodsIsPrimary = mContactMethodsInserter.getColumnIndex(ContactMethods.ISPRIMARY);
+        mIndexContactMethodsAuxData =
+                mContactMethodsInserter.getColumnIndex(ContactMethods.AUX_DATA);
+        mIndexContactMethodsIsPrimary =
+                mContactMethodsInserter.getColumnIndex(ContactMethods.ISPRIMARY);
 
         mOrganizationsInserter = new DatabaseUtils.InsertHelper(db, sOrganizationsTable);
-        mIndexOrganizationsPersonId = mOrganizationsInserter.getColumnIndex(Organizations.PERSON_ID);
+        mIndexOrganizationsPersonId =
+                mOrganizationsInserter.getColumnIndex(Organizations.PERSON_ID);
         mIndexOrganizationsLabel = mOrganizationsInserter.getColumnIndex(Organizations.LABEL);
         mIndexOrganizationsType = mOrganizationsInserter.getColumnIndex(Organizations.TYPE);
         mIndexOrganizationsCompany = mOrganizationsInserter.getColumnIndex(Organizations.COMPANY);
         mIndexOrganizationsTitle = mOrganizationsInserter.getColumnIndex(Organizations.TITLE);
-        mIndexOrganizationsIsPrimary = mOrganizationsInserter.getColumnIndex(Organizations.ISPRIMARY);
+        mIndexOrganizationsIsPrimary =
+                mOrganizationsInserter.getColumnIndex(Organizations.ISPRIMARY);
 
         mExtensionsInserter = new DatabaseUtils.InsertHelper(db, sExtensionsTable);
         mIndexExtensionsPersonId = mExtensionsInserter.getColumnIndex(Extensions.PERSON_ID);
@@ -263,9 +253,14 @@
         mIndexExtensionsValue = mExtensionsInserter.getColumnIndex(Extensions.VALUE);
 
         mGroupMembershipInserter = new DatabaseUtils.InsertHelper(db, sGroupmembershipTable);
-        mIndexGroupMembershipPersonId = mGroupMembershipInserter.getColumnIndex(GroupMembership.PERSON_ID);
-        mIndexGroupMembershipGroupSyncAccount = mGroupMembershipInserter.getColumnIndex(GroupMembership.GROUP_SYNC_ACCOUNT);
-        mIndexGroupMembershipGroupSyncId = mGroupMembershipInserter.getColumnIndex(GroupMembership.GROUP_SYNC_ID);
+        mIndexGroupMembershipPersonId =
+                mGroupMembershipInserter.getColumnIndex(GroupMembership.PERSON_ID);
+        mIndexGroupMembershipGroupSyncAccountName =
+                mGroupMembershipInserter.getColumnIndex(GroupMembership.GROUP_SYNC_ACCOUNT);
+        mIndexGroupMembershipGroupSyncAccountType =
+                mGroupMembershipInserter.getColumnIndex(GroupMembership.GROUP_SYNC_ACCOUNT_TYPE);
+        mIndexGroupMembershipGroupSyncId =
+                mGroupMembershipInserter.getColumnIndex(GroupMembership.GROUP_SYNC_ID);
 
         mCallsInserter = new DatabaseUtils.InsertHelper(db, sCallsTable);
 
@@ -307,23 +302,9 @@
             // use new token format from 73
             db.execSQL("delete from peopleLookup");
             try {
-                // With longForQuery(), _TOKERNIZE() is called just once, toward the first entry
-                // in "people" table. This may be a bug. Instead, we use rawQuery() for now. 
-                // DatabaseUtils.longForQuery(db, query, null);
-
-                // Cursors objects are lazily executed. So we have to call some method which forces
-                // the cursor to run the query.
-                Cursor cursor =
-                    db.rawQuery("SELECT _TOKENIZE('peopleLookup', _id, name, ' ') from people",
-                            null);
-                try {
-                    int rows = cursor.getCount();
-                    Log.i(TAG, "Processed " + rows + " contacts.");
-                } finally {
-                    if (cursor != null) {
-                        cursor.close();
-                    }
-                }
+                DatabaseUtils.longForQuery(db,
+                        "SELECT _TOKENIZE('peopleLookup', _id, name, ' ') from people;",
+                        null);
             } catch (SQLiteDoneException ex) {
                 // it is ok to throw this, 
                 // it just means you don't have data in people table
@@ -385,52 +366,71 @@
             oldVersion = 80;
         }
 
-        // Because of historical reason, version 81 have two types.
-        // 1) One type already has "peopleLookupWithPhoneticName" table but does not have
-        // "peopleLookup" table with token_index.
-        // 2) Another type has "peopleLookup" table with token_index but does not have
-        // "peopleLookupWithPhoneticName" table.
-        // For simplicity, both databases are once dropped here.
-        // This is slow but should be done just once anyway...
-        if (oldVersion == 80 || oldVersion == 81) {
+        if (oldVersion == 80) {
+            db.execSQL("ALTER TABLE people ADD COLUMN _sync_account_type TEXT;");
+            db.execSQL("ALTER TABLE _deleted_people ADD COLUMN _sync_account_type TEXT;");
+            db.execSQL("ALTER TABLE groups ADD COLUMN _sync_account_type TEXT;");
+            db.execSQL("ALTER TABLE _deleted_groups ADD COLUMN _sync_account_type TEXT;");
+            db.execSQL("ALTER TABLE settings ADD COLUMN _sync_account_type TEXT;");
+            db.execSQL("ALTER TABLE photos ADD COLUMN _sync_account_type TEXT;");
+            db.execSQL("ALTER TABLE groupmembership ADD COLUMN _sync_account_type TEXT;");
+
+            db.execSQL("UPDATE people"
+                    + " SET _sync_account_type='com.google.GAIA'"
+                    + " WHERE _sync_account IS NOT NULL");
+            db.execSQL("UPDATE _deleted_people"
+                    + " SET _sync_account_type='com.google.GAIA'"
+                    + " WHERE _sync_account IS NOT NULL");
+            db.execSQL("UPDATE groups"
+                    + " SET _sync_account_type='com.google.GAIA'"
+                    + " WHERE _sync_account IS NOT NULL");
+            db.execSQL("UPDATE _deleted_groups"
+                    + " SET _sync_account_type='com.google.GAIA'"
+                    + " WHERE _sync_account IS NOT NULL");
+            db.execSQL("UPDATE settings"
+                    + " SET _sync_account_type='com.google.GAIA'"
+                    + " WHERE _sync_account IS NOT NULL");
+            db.execSQL("UPDATE photos"
+                    + " SET _sync_account_type='com.google.GAIA'"
+                    + " WHERE _sync_account IS NOT NULL");
+            db.execSQL("UPDATE groupmembership"
+                    + " SET _sync_account_type='com.google.GAIA'"
+                    + " WHERE _sync_account IS NOT NULL");
+
+            db.execSQL("CREATE INDEX groupTempIndex ON groups ("
+                    + Groups.NAME + ","  + Groups.SYSTEM_ID + "," + Groups._SYNC_ACCOUNT + ","
+                    + Groups._SYNC_ACCOUNT_TYPE + ");");
+
+            db.execSQL("DROP INDEX groupmembershipIndex3");
+            db.execSQL("CREATE INDEX groupmembershipIndex3 ON groupmembership "
+                    + "(group_sync_account_type, group_sync_account, group_sync_id);");
+
+            // Trigger to move an account_people row to _deleted_account_people when it is deleted
+            db.execSQL("DROP TRIGGER groups_to_deleted");
+            db.execSQL("CREATE TRIGGER groups_to_deleted DELETE ON groups " +
+                        "WHEN old._sync_id is not null " +
+                        "BEGIN " +
+                            "INSERT INTO _deleted_groups " +
+                                "(_sync_id, _sync_account, _sync_account_type, _sync_version) " +
+                                "VALUES (old._sync_id, old._sync_account, old._sync_account_type," +
+                                "old._sync_version);" +
+                        "END");
+
+            oldVersion++;
+        }
+
+        if (oldVersion == 81) {
             Log.i(TAG, "Upgrading contacts database from version " + oldVersion + " to " +
                     newVersion + ", which will preserve existing data");
-
-            recreatePeopleLookupTable(db);
-            try {
-                String query = "SELECT _TOKENIZE('peopleLookup', _id, name, ' ', 1) FROM people";
-                Cursor cursor = db.rawQuery(query, null);
-                try {
-                    int rows = cursor.getCount();
-                    Log.i(TAG, "Processed " + rows + " contacts.");
-                } finally {
-                    if (cursor != null) {
-                        cursor.close();
-                    }
-                }
-            } catch (SQLiteException e) {
-                Log.e(TAG, e.toString() + ": " + e.getMessage());
-            }
-            
-            recreatePeopleLookupWithPhoneticNameTable(db);
-            try {
-                String query = "SELECT _TOKENIZE('peopleLookupWithPhoneticName', _id, "
-                    + PHONETIC_LOOKUP_SQL_SIMPLE +
-                    ", ' ', 1) FROM people";
-                Cursor cursor = db.rawQuery(query, null);
-                try {
-                    int rows = cursor.getCount();
-                    Log.i(TAG, "Processed " + rows + " contacts.");
-                } finally {
-                    if (cursor != null) {
-                        cursor.close();
-                    }
-                }
-            } catch (SQLiteException e) {
-                Log.e(TAG, e.toString() + ": " + e.getMessage());
-            }
-            
-            oldVersion = 82;
+            // 81 adds the token_index column
+            db.execSQL("DELETE FROM peopleLookup");
+            db.execSQL("ALTER TABLE peopleLookup ADD token_index INTEGER;");
+            String[] tokenize = {"_TOKENIZE('peopleLookup', _id, name, ' ', 1)"};
+            Cursor cursor = db.query("people", tokenize, null, null, null, null, null);
+            int rows = cursor.getCount();
+            cursor.close();
+            Log.i(TAG, "Processed " + rows + " contacts.");
+            oldVersion = 81;
         }
 
         return upgradeWasLossless;
@@ -439,7 +439,6 @@
     protected void dropTables(SQLiteDatabase db) {
         db.execSQL("DROP TABLE IF EXISTS people");
         db.execSQL("DROP TABLE IF EXISTS peopleLookup");
-        db.execSQL("DROP TABLE IF EXISTS peopleLookupWithPhoneticName");
         db.execSQL("DROP TABLE IF EXISTS _deleted_people");
         db.execSQL("DROP TABLE IF EXISTS phones");
         db.execSQL("DROP TABLE IF EXISTS contact_methods");
@@ -454,74 +453,13 @@
         db.execSQL("DROP TABLE IF EXISTS settings");
     }
 
-    private void recreatePeopleLookupTable(SQLiteDatabase db) {
-        db.execSQL("DROP TABLE IF EXISTS peopleLookup");
-        db.execSQL("DROP INDEX IF EXISTS peopleLookupIndex");
-        db.execSQL("DROP TRIGGER IF EXISTS peopleLookup_update"); 
-        db.execSQL("DROP TRIGGER IF EXISTS peopleLookup_insert");
-
-        db.execSQL("CREATE TABLE peopleLookup (" +
-                "token TEXT," +
-                "source INTEGER REFERENCES people(_id)," +
-                "token_index INTEGER" +
-                ");");
-        db.execSQL("CREATE INDEX peopleLookupIndex ON peopleLookup (" +
-                "token," +
-                "source" +
-                ");");
-        
-        // Triggers to keep the peopleLookup table up to date
-        db.execSQL("CREATE TRIGGER peopleLookup_update UPDATE OF name ON people " +
-                    "BEGIN " +
-                        "DELETE FROM peopleLookup WHERE source = new._id;" +
-                        "SELECT _TOKENIZE('peopleLookup', new._id, new.name, ' ', 1);" +
-                    "END");
-        db.execSQL("CREATE TRIGGER peopleLookup_insert AFTER INSERT ON people " +
-                    "BEGIN " +
-                        "SELECT _TOKENIZE('peopleLookup', new._id, new.name, ' ', 1);" +
-                    "END");
-    }
-    
-    private void recreatePeopleLookupWithPhoneticNameTable(SQLiteDatabase db) {
-        db.execSQL("DROP TABLE IF EXISTS peopleLookupWithPhoneticName");
-        db.execSQL("DROP INDEX IF EXISTS peopleLookupwithPhoneticNameIndex");
-        db.execSQL("DROP TRIGGER IF EXISTS peopleLookupWithPhoneticName_update");
-        db.execSQL("DROP TRIGGER IF EXISTS peopleLookupWithPhoneticName_insert");
-
-        db.execSQL("CREATE TABLE peopleLookupWithPhoneticName (" +
-                "token TEXT," +
-                "source INTEGER REFERENCES people(_id)," +
-                "token_index INTEGER" +
-                ");");
-        db.execSQL("CREATE INDEX peopleLookupWithPhoneticNameIndex ON " +
-                "peopleLookupWithPhoneticName (" +
-                "token," +
-                "source" +
-                ");");
-
-        // Triggers to keep the peopleLookupWithPhoneticName table up to date
-        db.execSQL("CREATE TRIGGER peopleLookupWithPhoneticName_update UPDATE OF " +
-                    "name, phonetic_name ON people " +
-                    "BEGIN " +
-                        "DELETE FROM peopleLookupWithPhoneticName WHERE source = new._id;" +
-                        "SELECT _TOKENIZE('peopleLookupWithPhoneticName', new._id, " +
-                        PHONETIC_LOOKUP_SQL_SIMPLE_WITH_NEW +
-                        ", ' ', 1);" +
-                    "END");
-        db.execSQL("CREATE TRIGGER peopleLookupWithPhoneticName_insert AFTER INSERT ON people " +
-                    "BEGIN " +
-                        "SELECT _TOKENIZE('peopleLookupWithPhoneticName', new._id, " +
-                        PHONETIC_LOOKUP_SQL_SIMPLE_WITH_NEW +
-                        ", ' ', 1);" +
-                    "END");
-    }
-    
     @Override
     protected void bootstrapDatabase(SQLiteDatabase db) {
         super.bootstrapDatabase(db);
         db.execSQL("CREATE TABLE people (" +
                     People._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
                     People._SYNC_ACCOUNT + " TEXT," + // From the sync source
+                    People._SYNC_ACCOUNT_TYPE + " TEXT," + // From the sync source
                     People._SYNC_ID + " TEXT," + // From the sync source
                     People._SYNC_TIME + " TEXT," + // From the sync source
                     People._SYNC_VERSION + " TEXT," + // From the sync source
@@ -560,6 +498,7 @@
         db.execSQL("CREATE TABLE groups (" +
                 Groups._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
                 Groups._SYNC_ACCOUNT + " TEXT," + // From the sync source
+                Groups._SYNC_ACCOUNT_TYPE + " TEXT," + // From the sync source
                 Groups._SYNC_ID + " TEXT," + // From the sync source
                 Groups._SYNC_TIME + " TEXT," + // From the sync source
                 Groups._SYNC_VERSION + " TEXT," + // From the sync source
@@ -574,7 +513,8 @@
                 Groups.SHOULD_SYNC + " INTEGER NOT NULL DEFAULT 0," +
                 Groups.SYSTEM_ID + " TEXT," +
                 "UNIQUE(" +
-                Groups.NAME + ","  + Groups.SYSTEM_ID + "," + Groups._SYNC_ACCOUNT + ")" +
+                Groups.NAME + ","  + Groups.SYSTEM_ID + "," + Groups._SYNC_ACCOUNT + "," +
+                Groups._SYNC_ACCOUNT_TYPE + ")" +
                 ");");
 
         db.execSQL("CREATE INDEX groupsSyncDirtyIndex ON groups (" + Groups._SYNC_DIRTY + ");");
@@ -603,6 +543,7 @@
                 + Photos.DATA + " BLOB,"
                 + Photos.SYNC_ERROR + " TEXT,"
                 + Photos._SYNC_ACCOUNT + " TEXT,"
+                + Photos._SYNC_ACCOUNT_TYPE + " TEXT,"
                 + Photos._SYNC_ID + " TEXT,"
                 + Photos._SYNC_TIME + " TEXT,"
                 + Photos._SYNC_VERSION + " TEXT,"
@@ -627,6 +568,7 @@
                     "_sync_id TEXT," +
                     (isTemporary() ? "_sync_local_id INTEGER," : "") + // Used while syncing,
                     "_sync_account TEXT," +
+                    "_sync_account_type TEXT," +
                     "_sync_mark INTEGER)"); // Used to filter out new rows
 
         db.execSQL("CREATE TABLE _deleted_groups (" +
@@ -634,6 +576,7 @@
                     "_sync_id TEXT," +
                     (isTemporary() ? "_sync_local_id INTEGER," : "") + // Used while syncing,
                     "_sync_account TEXT," +
+                    "_sync_account_type TEXT," +
                     "_sync_mark INTEGER)"); // Used to filter out new rows
 
         db.execSQL("CREATE TABLE phones (" +
@@ -680,6 +623,7 @@
         db.execSQL("CREATE TABLE settings (" +
                     "_id INTEGER PRIMARY KEY," +
                     "_sync_account TEXT," +
+                    "_sync_account_type TEXT," +
                     "key STRING NOT NULL," +
                     "value STRING " +
                     ");");
@@ -712,18 +656,18 @@
                 "person INTEGER REFERENCES people(_id)," +
                 "group_id INTEGER REFERENCES groups(_id)," +
                 "group_sync_account STRING," +
+                "group_sync_account_type STRING," +
                 "group_sync_id STRING" +
                 ");");
         db.execSQL("CREATE INDEX groupmembershipIndex1 ON groupmembership (person, group_id);");
         db.execSQL("CREATE INDEX groupmembershipIndex2 ON groupmembership (group_id, person);");
         db.execSQL("CREATE INDEX groupmembershipIndex3 ON groupmembership "
-                + "(group_sync_account, group_sync_id);");
+                + "(group_sync_account, group_sync_account_type, group_sync_id);");
 
         // Trigger to completely remove a contacts data when they're deleted
         db.execSQL("CREATE TRIGGER contact_cleanup DELETE ON people " +
                     "BEGIN " +
                         "DELETE FROM peopleLookup WHERE source = old._id;" +
-                        "DELETE FROM peopleLookupWithPhoneticName WHERE source = old._id;" +
                         "DELETE FROM phones WHERE person = old._id;" +
                         "DELETE FROM contact_methods WHERE person = old._id;" +
                         "DELETE FROM organizations WHERE person = old._id;" +
@@ -743,14 +687,22 @@
                     "WHEN old._sync_id is not null " +
                     "BEGIN " +
                         "INSERT INTO _deleted_groups " +
-                            "(_sync_id, _sync_account, _sync_version) " +
-                            "VALUES (old._sync_id, old._sync_account, " +
+                            "(_sync_id, _sync_account, _sync_account_type, _sync_version) " +
+                            "VALUES (old._sync_id, old._sync_account, old._sync_account_type, " +
                             "old._sync_version);" +
                     "END");
 
-        recreatePeopleLookupTable(db);
-        recreatePeopleLookupWithPhoneticNameTable(db);
-        
+        // Triggers to keep the peopleLookup table up to date
+        db.execSQL("CREATE TRIGGER peopleLookup_update UPDATE OF name ON people " +
+                    "BEGIN " +
+                        "DELETE FROM peopleLookup WHERE source = new._id;" +
+                        "SELECT _TOKENIZE('peopleLookup', new._id, new.name, ' ', 1);" +
+                    "END");
+        db.execSQL("CREATE TRIGGER peopleLookup_insert AFTER INSERT ON people " +
+                    "BEGIN " +
+                        "SELECT _TOKENIZE('peopleLookup', new._id, new.name, ' ', 1);" +
+                    "END");
+
         // Triggers to set the _sync_dirty flag when a phone is changed,
         // inserted or deleted
         db.execSQL("CREATE TRIGGER phones_update UPDATE ON phones " +
@@ -887,10 +839,10 @@
                 + Presence.PERSON_ID + ");");
     }
 
-    private String buildPeopleLookupWhereClauseCommon(String filterParam, String tableName) {
-        StringBuilder filter = new StringBuilder("people._id IN (SELECT source FROM ");
-        filter.append(tableName);
-        filter.append(" WHERE token GLOB ");
+    @SuppressWarnings("deprecation")
+    private String buildPeopleLookupWhereClause(String filterParam) {
+        StringBuilder filter = new StringBuilder(
+                "people._id IN (SELECT source FROM peopleLookup WHERE token 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.
@@ -899,18 +851,7 @@
         filter.append(')');
         return filter.toString();
     }
-    
-    private String buildPeopleLookupWhereClause(String filterParam) {
-        return buildPeopleLookupWhereClauseCommon(filterParam, "peopleLookup");
-    }
 
-    private String buildPeopleLookupWhereClauseForSuggestion(String filterParam) {
-        return buildPeopleLookupWhereClauseCommon(filterParam,
-                usePhoneticNameForPeopleLookup()
-                        ? "peopleLookupWithPhoneticName"
-                        : "peopleLookup");
-    }
-    
     @Override
     public Cursor queryInternal(Uri url, String[] projectionIn,
             String selection, String[] selectionArgs, String sort) {
@@ -1079,7 +1020,7 @@
             }
             case SEARCH_SHORTCUT: {
                 qb.setTables(PEOPLE_PHONES_PHOTOS_ORGANIZATIONS_JOIN);
-                qb.setProjectionMap(getCurrentSearchSuggestionsProjectionMap());
+                qb.setProjectionMap(sSearchSuggestionsProjectionMap);
                 qb.appendWhere(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID + "=");
                 qb.appendWhere(url.getPathSegments().get(1));
                 break;
@@ -1472,7 +1413,8 @@
         return "people._id IN (SELECT person FROM groupmembership JOIN groups " +
                 "ON (group_id=groups._id OR " +
                 "(group_sync_id = groups._sync_id AND " +
-                    "group_sync_account = groups._sync_account)) "+
+                    "group_sync_account = groups._sync_account AND " +
+                    "group_sync_account_type = groups._sync_account_type)) "+
                 "WHERE " + Groups.NAME + "="
                 + DatabaseUtils.sqlEscapeString(groupName) + ")";
     }
@@ -1511,17 +1453,6 @@
         }
     }
 
-    private HashMap<String, String> getCurrentSearchSuggestionsProjectionMap() {
-        String currentLanguage = Locale.getDefault().getLanguage();
-        synchronized (this) {
-            if (!currentLanguage.equals(mSearchSuggestionLanguage)) {
-                mSearchSuggestionLanguage = currentLanguage;
-                updateSuggestColumnTexts();
-            }
-        }
-        return mSearchSuggestionsProjectionMap; 
-    }
-    
     /**
      * Either sets up the query builder so we can run the proper query against the database
      * and returns null, or returns a cursor with the results already in it.
@@ -1532,7 +1463,7 @@
      */
     private Cursor handleSearchSuggestionsQuery(Uri url, SQLiteQueryBuilder qb) {
         qb.setTables(PEOPLE_PHONES_PHOTOS_ORGANIZATIONS_JOIN);
-        qb.setProjectionMap(getCurrentSearchSuggestionsProjectionMap());
+        qb.setProjectionMap(sSearchSuggestionsProjectionMap);
         if (url.getPathSegments().size() > 1) {
             // A search term was entered, use it to filter
 
@@ -1546,7 +1477,7 @@
             // match the query
             final String searchClause = url.getLastPathSegment();
             if (!TextUtils.isDigitsOnly(searchClause)) {
-                qb.appendWhere(buildPeopleLookupWhereClauseForSuggestion(searchClause));
+                qb.appendWhere(buildPeopleLookupWhereClause(searchClause));
             } else {
                 final String[] columnNames = new String[] {
                         "_id",
@@ -1699,18 +1630,19 @@
         return mime;
     }
 
-    private ContentValues queryAndroidStarredGroupId(String account) {
+    private ContentValues queryAndroidStarredGroupId(Account account) {
         String whereString;
         String[] whereArgs;
-        if (!TextUtils.isEmpty(account)) {
-            whereString = "_sync_account=? AND name=?";
-            whereArgs = new String[]{account, Groups.GROUP_ANDROID_STARRED};
+        if (account != null) {
+            whereString = "_sync_account=? AND _sync_account_type=? AND name=?";
+            whereArgs = new String[]{account.mName, account.mType, Groups.GROUP_ANDROID_STARRED};
         } else {
             whereString = "_sync_account is null AND name=?";
             whereArgs = new String[]{Groups.GROUP_ANDROID_STARRED};
         }
         Cursor cursor = getDatabase().query(sGroupsTable,
-                new String[]{Groups._ID, Groups._SYNC_ID, Groups._SYNC_ACCOUNT},
+                new String[]{Groups._ID, Groups._SYNC_ID, Groups._SYNC_ACCOUNT,
+                        Groups._SYNC_ACCOUNT_TYPE},
                 whereString, whereArgs, null, null, null);
         try {
             if (cursor.moveToNext()) {
@@ -1718,6 +1650,7 @@
                 result.put(Groups._ID, cursor.getLong(0));
                 result.put(Groups._SYNC_ID, cursor.getString(1));
                 result.put(Groups._SYNC_ACCOUNT, cursor.getString(2));
+                result.put(Groups._SYNC_ACCOUNT_TYPE, cursor.getString(3));
                 return result;
             }
             return null;
@@ -1783,9 +1716,11 @@
                 if (rowID > 0) {
                     resultUri = ContentUris.withAppendedId(Groups.CONTENT_URI, rowID);
                     if (!isTemporary() && newMap.containsKey(Groups.SHOULD_SYNC)) {
-                        final String account = newMap.getAsString(Groups._SYNC_ACCOUNT);
-                        if (!TextUtils.isEmpty(account)) {
+                        final String accountName = newMap.getAsString(Groups._SYNC_ACCOUNT);
+                        final String accountType = newMap.getAsString(Groups._SYNC_ACCOUNT_TYPE);
+                        if (!TextUtils.isEmpty(accountName) || !TextUtils.isEmpty(accountType)) {
                             final ContentResolver cr = getContext().getContentResolver();
+                            final Account account = new Account(accountName, accountType);
                             onLocalChangesForAccount(cr, account, false);
                         }
                     }
@@ -1804,7 +1739,12 @@
                 if (rowID > 0) {
                     resultUri = ContentUris.withAppendedId(People.CONTENT_URI, rowID);
                     if (!isTemporary()) {
-                        String account = mValues.getAsString(People._SYNC_ACCOUNT);
+                        String accountName = mValues.getAsString(People._SYNC_ACCOUNT);
+                        String accountType = mValues.getAsString(People._SYNC_ACCOUNT_TYPE);
+                        Account account = null;
+                        if (accountName != null || accountType != null) {
+                            account = new Account(accountName, accountType);
+                        }
                         Long starredValue = mValues.getAsLong(People.STARRED);
                         final String syncId = mValues.getAsString(People._SYNC_ID);
                         boolean isStarred = starredValue != null && starredValue != 0;
@@ -1813,7 +1753,8 @@
                         mDb.delete(sPhotosTable, "person=" + rowID, null);
                         mValues.clear();
                         mValues.put(Photos.PERSON_ID, rowID);
-                        mValues.put(Photos._SYNC_ACCOUNT, account);
+                        mValues.put(Photos._SYNC_ACCOUNT, accountName);
+                        mValues.put(Photos._SYNC_ACCOUNT_TYPE, accountType);
                         mValues.put(Photos._SYNC_ID, syncId);
                         mValues.put(Photos._SYNC_DIRTY, 0);
                         mPhotosInserter.insert(mValues);
@@ -1973,19 +1914,26 @@
     }
 
     @Override
-    protected void onAccountsChanged(String[] accountsArray) {
+    protected void onAccountsChanged(Account[] accountsArray) {
         super.onAccountsChanged(accountsArray);
         synchronized (mAccountsLock) {
-            mAccounts = new String[accountsArray.length];
+            mAccounts = new Account[accountsArray.length];
             System.arraycopy(accountsArray, 0, mAccounts, 0, mAccounts.length);
         }
     }
 
     private void ensureSyncAccountIsSet(ContentValues values) {
         synchronized (mAccountsLock) {
-            String account = values.getAsString(SyncConstValue._SYNC_ACCOUNT);
+            final String accountName = values.getAsString(SyncConstValue._SYNC_ACCOUNT);
+            final String accountType = values.getAsString(SyncConstValue._SYNC_ACCOUNT_TYPE);
+            Account account = null;
+            if (!TextUtils.isEmpty(accountName) || !TextUtils.isEmpty(accountType)) {
+                account = new Account(accountName, accountType);
+            }
             if (account == null && mAccounts.length > 0) {
-                values.put(SyncConstValue._SYNC_ACCOUNT, mAccounts[0]);
+                // TODO(fredq) change this to pick the account that is syncable for contacts
+                values.put(SyncConstValue._SYNC_ACCOUNT, mAccounts[0].mName);
+                values.put(SyncConstValue._SYNC_ACCOUNT_TYPE, mAccounts[0].mType);
             }
         }
     }
@@ -2008,21 +1956,28 @@
     }
 
     private Uri insertIntoGroupmembership(ContentValues values) {
-        String groupSyncAccount = values.getAsString(GroupMembership.GROUP_SYNC_ACCOUNT);
+        String groupSyncAccountName = values.getAsString(GroupMembership.GROUP_SYNC_ACCOUNT);
+        String groupSyncAccountType = values.getAsString(GroupMembership.GROUP_SYNC_ACCOUNT_TYPE);
         String groupSyncId = values.getAsString(GroupMembership.GROUP_SYNC_ID);
         final Long personId = values.getAsLong(GroupMembership.PERSON_ID);
         if (!values.containsKey(GroupMembership.GROUP_ID)) {
-            if (TextUtils.isEmpty(groupSyncAccount) || TextUtils.isEmpty(groupSyncId)) {
+            if (TextUtils.isEmpty(groupSyncAccountName) || TextUtils.isEmpty(groupSyncAccountType)
+                    || TextUtils.isEmpty(groupSyncId)) {
                 throw new IllegalArgumentException(
                         "insertIntoGroupmembership: no GROUP_ID wasn't specified and non-empty "
-                        + "GROUP_SYNC_ID and GROUP_SYNC_ACCOUNT fields weren't specifid, "
+                        + "GROUP_SYNC_ID and GROUP_SYNC_ACCOUNT and GROUP_SYNC_ACCOUNT_TYPE fields "
+                        + "weren't specifid, "
                         + values);
             }
             if (0 != DatabaseUtils.longForQuery(getDatabase(), ""
                     + "SELECT COUNT(*) "
                     + "FROM groupmembership "
-                    + "WHERE group_sync_id=? AND person=?",
-                    new String[]{groupSyncId, String.valueOf(personId)})) {
+                    + "WHERE group_sync_id=? "
+                    + "  AND group_sync_account=? "
+                    + "  AND group_sync_account_type=? "
+                    + "  AND person=?",
+                    new String[]{groupSyncId, groupSyncAccountName, groupSyncAccountType,
+                            String.valueOf(personId)})) {
                 final String errorMessage =
                         "insertIntoGroupmembership: a row with this server key already exists, "
                                 + values;
@@ -2031,10 +1986,12 @@
             }
         } else {
             long groupId = values.getAsLong(GroupMembership.GROUP_ID);
-            if (!TextUtils.isEmpty(groupSyncAccount) || !TextUtils.isEmpty(groupSyncId)) {
+            if (!TextUtils.isEmpty(groupSyncAccountName) || !TextUtils.isEmpty(groupSyncAccountType)
+                    || !TextUtils.isEmpty(groupSyncId)) {
                 throw new IllegalArgumentException(
                         "insertIntoGroupmembership: GROUP_ID was specified but "
-                        + "GROUP_SYNC_ID and GROUP_SYNC_ACCOUNT fields were also specifid, "
+                        + "GROUP_SYNC_ID and GROUP_SYNC_ACCOUNT and GROUP_SYNC_ACCOUNT_TYPE fields "
+                        + "were also specifid, "
                         + values);
             }
             if (0 != DatabaseUtils.longForQuery(getDatabase(),
@@ -2064,7 +2021,7 @@
         return ContentUris.withAppendedId(GroupMembership.CONTENT_URI, rowId);
     }
 
-    private void fixupGroupMembershipAfterPeopleUpdate(String account, long personId,
+    private void fixupGroupMembershipAfterPeopleUpdate(Account account, long personId,
             boolean makeStarred) {
         ContentValues starredGroupInfo = queryAndroidStarredGroupId(account);
         if (makeStarred) {
@@ -2073,17 +2030,22 @@
                 mValuesLocal.clear();
                 mValuesLocal.put(Groups.NAME, Groups.GROUP_ANDROID_STARRED);
                 mValuesLocal.put(Groups._SYNC_DIRTY, 1);
-                mValuesLocal.put(Groups._SYNC_ACCOUNT, account);
+                mValuesLocal.put(Groups._SYNC_ACCOUNT, account == null ? null : account.mName);
+                mValuesLocal.put(Groups._SYNC_ACCOUNT_TYPE, account == null ? null : account.mType);
                 long groupId = mGroupsInserter.insert(mValuesLocal);
                 starredGroupInfo = new ContentValues();
                 starredGroupInfo.put(Groups._ID, groupId);
-                starredGroupInfo.put(Groups._SYNC_ACCOUNT, account);
+                starredGroupInfo.put(Groups._SYNC_ACCOUNT,
+                        mValuesLocal.getAsString(Groups._SYNC_ACCOUNT));
+                starredGroupInfo.put(Groups._SYNC_ACCOUNT_TYPE,
+                        mValuesLocal.getAsString(Groups._SYNC_ACCOUNT_TYPE));
                 // don't put the _SYNC_ID in here since we don't know it yet
             }
 
             final Long groupId = starredGroupInfo.getAsLong(Groups._ID);
             final String syncId = starredGroupInfo.getAsString(Groups._SYNC_ID);
-            final String syncAccount = starredGroupInfo.getAsString(Groups._SYNC_ACCOUNT);
+            final String syncAccountName = starredGroupInfo.getAsString(Groups._SYNC_ACCOUNT);
+            final String syncAccountType = starredGroupInfo.getAsString(Groups._SYNC_ACCOUNT_TYPE);
 
             // check that either groupId is set or the syncId/Account is set
             final boolean hasSyncId = !TextUtils.isEmpty(syncId);
@@ -2098,17 +2060,21 @@
             mValuesLocal.put(GroupMembership.PERSON_ID, personId);
             mValuesLocal.put(GroupMembership.GROUP_ID, groupId);
             mValuesLocal.put(GroupMembership.GROUP_SYNC_ID, syncId);
-            mValuesLocal.put(GroupMembership.GROUP_SYNC_ACCOUNT, syncAccount);
+            mValuesLocal.put(GroupMembership.GROUP_SYNC_ACCOUNT, syncAccountName);
+            mValuesLocal.put(GroupMembership.GROUP_SYNC_ACCOUNT_TYPE, syncAccountType);
             mGroupMembershipInserter.insert(mValuesLocal);
         } else {
             if (starredGroupInfo != null) {
                 // delete the groupmembership rows for this person that match the starred group id
-                String syncAccount = starredGroupInfo.getAsString(Groups._SYNC_ACCOUNT);
+                String syncAccountName = starredGroupInfo.getAsString(Groups._SYNC_ACCOUNT);
+                String syncAccountType = starredGroupInfo.getAsString(Groups._SYNC_ACCOUNT_TYPE);
                 String syncId = starredGroupInfo.getAsString(Groups._SYNC_ID);
                 if (!TextUtils.isEmpty(syncId)) {
                     mDb.delete(sGroupmembershipTable,
-                            "person=? AND group_sync_id=? AND group_sync_account=?",
-                            new String[]{String.valueOf(personId), syncId, syncAccount});
+                            "person=? AND group_sync_id=? AND group_sync_account=?"
+                                    + " AND group_sync_account_type=?",
+                            new String[]{String.valueOf(personId), syncId,
+                                    syncAccountName, syncAccountType});
                 } else {
                     mDb.delete(sGroupmembershipTable, "person=? AND group_id=?",
                             new String[]{
@@ -2346,11 +2312,14 @@
         Cursor cursor = db.query(sPeopleTable, null, where, whereArgs, null, null, null);
         try {
             final int idxSyncId = cursor.getColumnIndexOrThrow(People._SYNC_ID);
-            final int idxSyncAccount = cursor.getColumnIndexOrThrow(People._SYNC_ACCOUNT);
+            final int idxSyncAccountName = cursor.getColumnIndexOrThrow(People._SYNC_ACCOUNT);
+            final int idxSyncAccountType = cursor.getColumnIndexOrThrow(People._SYNC_ACCOUNT_TYPE);
             final int idxSyncVersion = cursor.getColumnIndexOrThrow(People._SYNC_VERSION);
             final int dstIdxSyncId = mDeletedPeopleInserter.getColumnIndex(SyncConstValue._SYNC_ID);
-            final int dstIdxSyncAccount =
+            final int dstIdxSyncAccountName =
                     mDeletedPeopleInserter.getColumnIndex(SyncConstValue._SYNC_ACCOUNT);
+            final int dstIdxSyncAccountType =
+                    mDeletedPeopleInserter.getColumnIndex(SyncConstValue._SYNC_ACCOUNT_TYPE);
             final int dstIdxSyncVersion =
                     mDeletedPeopleInserter.getColumnIndex(SyncConstValue._SYNC_VERSION);
             while (cursor.moveToNext()) {
@@ -2359,7 +2328,10 @@
                 // insert into deleted table
                 mDeletedPeopleInserter.prepareForInsert();
                 mDeletedPeopleInserter.bind(dstIdxSyncId, syncId);
-                mDeletedPeopleInserter.bind(dstIdxSyncAccount, cursor.getString(idxSyncAccount));
+                mDeletedPeopleInserter.bind(dstIdxSyncAccountName,
+                        cursor.getString(idxSyncAccountName));
+                mDeletedPeopleInserter.bind(dstIdxSyncAccountType,
+                        cursor.getString(idxSyncAccountType));
                 mDeletedPeopleInserter.bind(dstIdxSyncVersion, cursor.getString(idxSyncVersion));
                 mDeletedPeopleInserter.execute();
             }
@@ -2372,27 +2344,31 @@
     }
 
     private int deleteFromGroups(String where, String[] whereArgs) {
-        HashSet<String> modifiedAccounts = Sets.newHashSet();
+        HashSet<Account> modifiedAccounts = Sets.newHashSet();
         Cursor cursor = getDatabase().query(sGroupsTable, null, where, whereArgs,
                 null, null, null);
         try {
             final int indexName = cursor.getColumnIndexOrThrow(Groups.NAME);
             final int indexSyncAccount = cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT);
+            final int indexSyncAccountType =
+                    cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT_TYPE);
             final int indexSyncId = cursor.getColumnIndexOrThrow(Groups._SYNC_ID);
             final int indexId = cursor.getColumnIndexOrThrow(Groups._ID);
             final int indexShouldSync = cursor.getColumnIndexOrThrow(Groups.SHOULD_SYNC);
             while (cursor.moveToNext()) {
                 String oldName = cursor.getString(indexName);
-                String syncAccount = cursor.getString(indexSyncAccount);
+                String syncAccountName = cursor.getString(indexSyncAccount);
+                String syncAccountType = cursor.getString(indexSyncAccountType);
                 String syncId = cursor.getString(indexSyncId);
                 boolean shouldSync = cursor.getLong(indexShouldSync) != 0;
                 long id = cursor.getLong(indexId);
                 fixupPeopleStarredOnGroupRename(oldName, null, id);
-                if (!TextUtils.isEmpty(syncAccount) && !TextUtils.isEmpty(syncId)) {
-                    fixupPeopleStarredOnGroupRename(oldName, null, syncAccount, syncId);
+                if (!TextUtils.isEmpty(syncAccountName) && !TextUtils.isEmpty(syncId)) {
+                    fixupPeopleStarredOnGroupRename(oldName, null,
+                            new Account(syncAccountName, syncAccountType), syncId);
                 }
-                if (!TextUtils.isEmpty(syncAccount) && shouldSync) {
-                    modifiedAccounts.add(syncAccount);
+                if (!TextUtils.isEmpty(syncAccountName) && shouldSync) {
+                    modifiedAccounts.add(new Account(syncAccountName, syncAccountType));
                 }
             }
         } finally {
@@ -2403,7 +2379,7 @@
         if (numRows > 0) {
             if (!isTemporary()) {
                 final ContentResolver cr = getContext().getContentResolver();
-                for (String account : modifiedAccounts) {
+                for (Account account : modifiedAccounts) {
                     onLocalChangesForAccount(cr, account, true);
                 }
             }
@@ -2418,7 +2394,7 @@
      * @param resolver the content resolver to use
      * @param account the account the changes are tied to
      */
-    protected void onLocalChangesForAccount(final ContentResolver resolver, String account,
+    protected void onLocalChangesForAccount(final ContentResolver resolver, Account account,
             boolean groupsModified) {
         // Do nothing
     }
@@ -2603,7 +2579,9 @@
                         Cursor c = mDb.query(sPeopleTable, null,
                                 where, whereArgs, null, null, null);
                         try {
-                            int indexAccount = c.getColumnIndexOrThrow(People._SYNC_ACCOUNT);
+                            int indexAccountName = c.getColumnIndexOrThrow(People._SYNC_ACCOUNT);
+                            int indexAccountType =
+                                    c.getColumnIndexOrThrow(People._SYNC_ACCOUNT_TYPE);
                             int indexId = c.getColumnIndexOrThrow(People._ID);
                             Long starredValue = values.getAsLong(People.STARRED);
                             Long primaryPhone = values.getAsLong(People.PRIMARY_PHONE_ID);
@@ -2613,7 +2591,13 @@
                             while (c.moveToNext()) {
                                 final long personId = c.getLong(indexId);
                                 if (hasStarred) {
-                                    fixupGroupMembershipAfterPeopleUpdate(c.getString(indexAccount),
+                                    final String accountName = c.getString(indexAccountName);
+                                    final String accountType = c.getString(indexAccountType);
+                                    final Account account =
+                                            (accountName != null || accountType != null)
+                                                    ? new Account(accountName, accountType)
+                                                    : null;
+                                    fixupGroupMembershipAfterPeopleUpdate(account,
                                             personId, starredValue != null && starredValue != 0);
                                 }
 
@@ -2654,18 +2638,21 @@
 
     private int updateSettings(ContentValues values) {
         final SQLiteDatabase db = getDatabase();
-        final String account = values.getAsString(Contacts.Settings._SYNC_ACCOUNT);
+        final String accountName = values.getAsString(Contacts.Settings._SYNC_ACCOUNT);
+        final String accountType = values.getAsString(Contacts.Settings._SYNC_ACCOUNT_TYPE);
         final String key = values.getAsString(Contacts.Settings.KEY);
         if (key == null) {
             throw new IllegalArgumentException("you must specify the key when updating settings");
         }
+        Account account = null;
+        if (accountName != null || accountType != null) {
+            account = new Account(accountName, accountType);
+        }
         if (account == null) {
             db.delete(sSettingsTable, "_sync_account IS NULL AND key=?", new String[]{key});
         } else {
-            if (TextUtils.isEmpty(account)) {
-                throw new IllegalArgumentException("account cannot be the empty string, " + values);
-            }
-            db.delete(sSettingsTable, "_sync_account=? AND key=?", new String[]{account, key});
+            db.delete(sSettingsTable, "_sync_account=? AND _sync_account_type=? AND key=?",
+                    new String[]{account.mName, account.mType, key});
         }
         long rowId = db.insert(sSettingsTable, Contacts.Settings.KEY, values);
         if (rowId < 0) {
@@ -2684,7 +2671,7 @@
             }
         }
 
-        Set<String> modifiedAccounts = Sets.newHashSet();
+        Set<Account> modifiedAccounts = Sets.newHashSet();
         final SQLiteDatabase db = getDatabase();
         if (values.containsKey(Groups.NAME) || values.containsKey(Groups.SHOULD_SYNC)) {
             String newName = values.getAsString(Groups.NAME);
@@ -2692,21 +2679,25 @@
             try {
                 final int indexName = cursor.getColumnIndexOrThrow(Groups.NAME);
                 final int indexSyncAccount = cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT);
+                final int indexSyncAccountType =
+                        cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT_TYPE);
                 final int indexSyncId = cursor.getColumnIndexOrThrow(Groups._SYNC_ID);
                 final int indexId = cursor.getColumnIndexOrThrow(Groups._ID);
                 while (cursor.moveToNext()) {
-                    String syncAccount = cursor.getString(indexSyncAccount);
+                    String accountName = cursor.getString(indexSyncAccount);
+                    String accountType = cursor.getString(indexSyncAccountType);
                     if (values.containsKey(Groups.NAME)) {
                         String oldName = cursor.getString(indexName);
                         String syncId = cursor.getString(indexSyncId);
                         long id = cursor.getLong(indexId);
                         fixupPeopleStarredOnGroupRename(oldName, newName, id);
-                        if (!TextUtils.isEmpty(syncAccount) && !TextUtils.isEmpty(syncId)) {
-                            fixupPeopleStarredOnGroupRename(oldName, newName, syncAccount, syncId);
+                        if (!TextUtils.isEmpty(accountName) && !TextUtils.isEmpty(syncId)) {
+                            fixupPeopleStarredOnGroupRename(oldName, newName,
+                                    new Account(accountName, accountType), syncId);
                         }
                     }
-                    if (!TextUtils.isEmpty(syncAccount) && values.containsKey(Groups.SHOULD_SYNC)) {
-                        modifiedAccounts.add(syncAccount);
+                    if (!TextUtils.isEmpty(accountName) && values.containsKey(Groups.SHOULD_SYNC)) {
+                        modifiedAccounts.add(new Account(accountName, accountType));
                     }
                 }
             } finally {
@@ -2718,7 +2709,7 @@
         if (numRows > 0) {
             if (!isTemporary()) {
                 final ContentResolver cr = getContext().getContentResolver();
-                for (String account : modifiedAccounts) {
+                for (Account account : modifiedAccounts) {
                     onLocalChangesForAccount(cr, account, true);
                 }
             }
@@ -2747,9 +2738,10 @@
     }
 
     void fixupPeopleStarredOnGroupRename(String oldName, String newName,
-            String syncAccount, String syncId) {
-        fixupPeopleStarredOnGroupRename(oldName, newName, "_sync_account=? AND _sync_id=?",
-                new String[]{syncAccount, syncId});
+            Account syncAccount, String syncId) {
+        fixupPeopleStarredOnGroupRename(oldName, newName,
+                "_sync_account=? AND _sync_account_type=? AND _sync_id=?",
+                new String[]{syncAccount.mName, syncAccount.mType, syncId});
     }
 
     void fixupPeopleStarredOnGroupRename(String oldName, String newName, long groupId) {
@@ -3106,14 +3098,24 @@
 
             // Copy the person
             mPeopleInserter.prepareForInsert();
-            DatabaseUtils.cursorStringToInsertHelper(diffsCursor, People._SYNC_ID, mPeopleInserter, mIndexPeopleSyncId);
-            DatabaseUtils.cursorStringToInsertHelper(diffsCursor, People._SYNC_TIME, mPeopleInserter, mIndexPeopleSyncTime);
-            DatabaseUtils.cursorStringToInsertHelper(diffsCursor, People._SYNC_VERSION, mPeopleInserter, mIndexPeopleSyncVersion);
-            DatabaseUtils.cursorStringToInsertHelper(diffsCursor, People._SYNC_DIRTY, mPeopleInserter, mIndexPeopleSyncDirty);
-            DatabaseUtils.cursorStringToInsertHelper(diffsCursor, People._SYNC_ACCOUNT, mPeopleInserter, mIndexPeopleSyncAccount);
-            DatabaseUtils.cursorStringToInsertHelper(diffsCursor, People.NAME, mPeopleInserter, mIndexPeopleName);
-            DatabaseUtils.cursorStringToInsertHelper(diffsCursor, People.PHONETIC_NAME, mPeopleInserter, mIndexPeoplePhoneticName);
-            DatabaseUtils.cursorStringToInsertHelper(diffsCursor, People.NOTES, mPeopleInserter, mIndexPeopleNotes);
+            DatabaseUtils.cursorStringToInsertHelper(diffsCursor,
+                    People._SYNC_ID, mPeopleInserter, mIndexPeopleSyncId);
+            DatabaseUtils.cursorStringToInsertHelper(diffsCursor,
+                    People._SYNC_TIME, mPeopleInserter, mIndexPeopleSyncTime);
+            DatabaseUtils.cursorStringToInsertHelper(diffsCursor,
+                    People._SYNC_VERSION, mPeopleInserter, mIndexPeopleSyncVersion);
+            DatabaseUtils.cursorStringToInsertHelper(diffsCursor,
+                    People._SYNC_DIRTY, mPeopleInserter, mIndexPeopleSyncDirty);
+            DatabaseUtils.cursorStringToInsertHelper(diffsCursor,
+                    People._SYNC_ACCOUNT, mPeopleInserter, mIndexPeopleSyncAccountName);
+            DatabaseUtils.cursorStringToInsertHelper(diffsCursor,
+                    People._SYNC_ACCOUNT_TYPE, mPeopleInserter, mIndexPeopleSyncAccountType);
+            DatabaseUtils.cursorStringToInsertHelper(diffsCursor,
+                    People.NAME, mPeopleInserter, mIndexPeopleName);
+            DatabaseUtils.cursorStringToInsertHelper(diffsCursor,
+                    People.PHONETIC_NAME, mPeopleInserter, mIndexPeoplePhoneticName);
+            DatabaseUtils.cursorStringToInsertHelper(diffsCursor,
+                    People.NOTES, mPeopleInserter, mIndexPeopleNotes);
             long localPersonID = mPeopleInserter.execute();
 
             Cursor c;
@@ -3133,7 +3135,9 @@
                     DatabaseUtils.cursorStringToInsertHelper(c, Photos._SYNC_VERSION,
                             mPhotosInserter, mIndexPhotosSyncVersion);
                     DatabaseUtils.cursorStringToInsertHelper(c, Photos._SYNC_ACCOUNT,
-                            mPhotosInserter, mIndexPhotosSyncAccount);
+                            mPhotosInserter, mIndexPhotosSyncAccountName);
+                    DatabaseUtils.cursorStringToInsertHelper(c, Photos._SYNC_ACCOUNT_TYPE,
+                            mPhotosInserter, mIndexPhotosSyncAccountType);
                     DatabaseUtils.cursorStringToInsertHelper(c, Photos.EXISTS_ON_SERVER,
                             mPhotosInserter, mIndexPhotosExistsOnServer);
                     mPhotosInserter.bind(mIndexPhotosSyncError, (String)null);
@@ -3209,11 +3213,13 @@
                     final int isPrimaryValue = c.getInt(primaryIndex);
                     mContactMethodsInserter.prepareForInsert();
                     mContactMethodsInserter.bind(mIndexContactMethodsPersonId, localPersonID);
-                    mContactMethodsInserter.bind(mIndexContactMethodsLabel, c.getString(labelIndex));
+                    mContactMethodsInserter.bind(mIndexContactMethodsLabel,
+                            c.getString(labelIndex));
                     mContactMethodsInserter.bind(mIndexContactMethodsKind, kind);
                     mContactMethodsInserter.bind(mIndexContactMethodsType, type);
                     mContactMethodsInserter.bind(mIndexContactMethodsData, c.getString(dataIndex));
-                    mContactMethodsInserter.bind(mIndexContactMethodsAuxData, c.getString(auxDataIndex));
+                    mContactMethodsInserter.bind(mIndexContactMethodsAuxData,
+                            c.getString(auxDataIndex));
                     mContactMethodsInserter.bind(mIndexContactMethodsIsPrimary, isPrimaryValue);
                     long rowId = mContactMethodsInserter.execute();
                     if ((kind == Contacts.KIND_EMAIL) && (isPrimaryValue != 0)) {
@@ -3258,7 +3264,8 @@
                     mOrganizationsInserter.bind(mIndexOrganizationsPersonId, localPersonID);
                     mOrganizationsInserter.bind(mIndexOrganizationsLabel, c.getString(labelIndex));
                     mOrganizationsInserter.bind(mIndexOrganizationsType, type);
-                    mOrganizationsInserter.bind(mIndexOrganizationsCompany, c.getString(companyIndex));
+                    mOrganizationsInserter.bind(mIndexOrganizationsCompany,
+                            c.getString(companyIndex));
                     mOrganizationsInserter.bind(mIndexOrganizationsTitle, c.getString(titleIndex));
                     mOrganizationsInserter.bind(mIndexOrganizationsIsPrimary, isPrimaryValue);
                     long rowId = mOrganizationsInserter.execute();
@@ -3291,14 +3298,20 @@
             c = doSubQuery(diffsDb, sGroupmembershipTable, null, diffsPersonID,
                     sGroupmembershipTable + "._id");
             try {
-                final int accountIndex =
+                final int accountNameIndex =
                     c.getColumnIndexOrThrow(GroupMembership.GROUP_SYNC_ACCOUNT);
+                final int accountTypeIndex =
+                    c.getColumnIndexOrThrow(GroupMembership.GROUP_SYNC_ACCOUNT_TYPE);
                 final int idIndex = c.getColumnIndexOrThrow(GroupMembership.GROUP_SYNC_ID);
                 while(c.moveToNext()) {
                     mGroupMembershipInserter.prepareForInsert();
                     mGroupMembershipInserter.bind(mIndexGroupMembershipPersonId, localPersonID);
-                    mGroupMembershipInserter.bind(mIndexGroupMembershipGroupSyncAccount, c.getString(accountIndex));
-                    mGroupMembershipInserter.bind(mIndexGroupMembershipGroupSyncId, c.getString(idIndex));
+                    mGroupMembershipInserter.bind(mIndexGroupMembershipGroupSyncAccountName,
+                            c.getString(accountNameIndex));
+                    mGroupMembershipInserter.bind(mIndexGroupMembershipGroupSyncAccountType,
+                            c.getString(accountTypeIndex));
+                    mGroupMembershipInserter.bind(mIndexGroupMembershipGroupSyncId,
+                            c.getString(idIndex));
                     mGroupMembershipInserter.execute();
                 }
             } finally {
@@ -3306,7 +3319,8 @@
             }
 
             // Copy all extensions rows
-            c = doSubQuery(diffsDb, sExtensionsTable, null, diffsPersonID, sExtensionsTable + "._id");
+            c = doSubQuery(diffsDb, sExtensionsTable, null, diffsPersonID,
+                    sExtensionsTable + "._id");
             try {
                 final int nameIndex = c.getColumnIndexOrThrow(Extensions.NAME);
                 final int valueIndex = c.getColumnIndexOrThrow(Extensions.VALUE);
@@ -3595,6 +3609,8 @@
                             DatabaseUtils.cursorStringToContentValues(cRemote,
                                     GroupMembership.GROUP_SYNC_ACCOUNT, mValues);
                             DatabaseUtils.cursorStringToContentValues(cRemote,
+                                    GroupMembership.GROUP_SYNC_ACCOUNT_TYPE, mValues);
+                            DatabaseUtils.cursorStringToContentValues(cRemote,
                                     GroupMembership.GROUP_SYNC_ID, mValues);
                             if (joinResult == CursorJoiner.Result.RIGHT) {
                                 mValues.put(GroupMembership.PERSON_ID, localPersonID);
@@ -3660,7 +3676,10 @@
                 if(cRemote.moveToNext()) {
                     mValues.clear();
                     DatabaseUtils.cursorStringToContentValues(cRemote, Photos._SYNC_ID, mValues);
-                    DatabaseUtils.cursorStringToContentValues(cRemote, Photos._SYNC_ACCOUNT, mValues);
+                    DatabaseUtils.cursorStringToContentValues(cRemote,
+                            Photos._SYNC_ACCOUNT, mValues);
+                    DatabaseUtils.cursorStringToContentValues(cRemote,
+                            Photos._SYNC_ACCOUNT_TYPE, mValues);
                     db.update(sPhotosTable, mValues, Photos.PERSON_ID + '=' + localPersonID, null);
                 }
             } finally {
@@ -3687,6 +3706,8 @@
             DatabaseUtils.cursorStringToContentValues(diffsCursor, People._SYNC_TIME, mValues);
             DatabaseUtils.cursorStringToContentValues(diffsCursor, People._SYNC_VERSION, mValues);
             DatabaseUtils.cursorStringToContentValues(diffsCursor, People._SYNC_ACCOUNT, mValues);
+            DatabaseUtils.cursorStringToContentValues(diffsCursor,
+                    People._SYNC_ACCOUNT_TYPE, mValues);
             DatabaseUtils.cursorStringToContentValues(diffsCursor, People.NAME, mValues);
             DatabaseUtils.cursorStringToContentValues(diffsCursor, People.PHONETIC_NAME, mValues);
             DatabaseUtils.cursorStringToContentValues(diffsCursor, People.NOTES, mValues);
@@ -3794,6 +3815,7 @@
             DatabaseUtils.cursorStringToContentValues(cursor, Groups._SYNC_TIME, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups._SYNC_VERSION, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups._SYNC_ACCOUNT, mValues);
+            DatabaseUtils.cursorStringToContentValues(cursor, Groups._SYNC_ACCOUNT_TYPE, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups.NAME, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups.NOTES, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups.SYSTEM_ID, mValues);
@@ -3816,16 +3838,21 @@
                 // We may have just synced the metadata for a groups we previously marked for
                 // syncing.
                 final ContentResolver cr = getContext().getContentResolver();
-                final String account = mValues.getAsString(Groups._SYNC_ACCOUNT);
-                onLocalChangesForAccount(cr, account, false);
+                final String accountName = mValues.getAsString(Groups._SYNC_ACCOUNT);
+                final String accountType = mValues.getAsString(Groups._SYNC_ACCOUNT_TYPE);
+                onLocalChangesForAccount(cr, new Account(accountName, accountType), false);
             }
 
             String oldName = null;
             String newName = cursor.getString(cursor.getColumnIndexOrThrow(Groups.NAME));
-            String account = cursor.getString(cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT));
+            String accountName = cursor.getString(
+                    cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT));
+            String accountType = cursor.getString(
+                    cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT_TYPE));
             String syncId = cursor.getString(cursor.getColumnIndexOrThrow(Groups._SYNC_ID));
             // this must come after the insert, otherwise the join won't work
-            fixupPeopleStarredOnGroupRename(oldName, newName, account, syncId);
+            fixupPeopleStarredOnGroupRename(oldName, newName, new Account(accountName, accountType),
+                    syncId);
         }
 
         @Override
@@ -3846,16 +3873,21 @@
             String oldName = DatabaseUtils.stringForQuery(db,
                     "select name from groups where _id=" + localRowId, null);
             String newName = cursor.getString(cursor.getColumnIndexOrThrow(Groups.NAME));
-            String account = cursor.getString(cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT));
+            String accountName = cursor.getString(
+                    cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT));
+            String accountType = cursor.getString(
+                    cursor.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT_TYPE));
             String syncId = cursor.getString(cursor.getColumnIndexOrThrow(Groups._SYNC_ID));
             // this can come before or after the delete
-            fixupPeopleStarredOnGroupRename(oldName, newName, account, syncId);
+            fixupPeopleStarredOnGroupRename(oldName, newName,
+                    new Account(accountName, accountType), syncId);
 
             mValues.clear();
             DatabaseUtils.cursorStringToContentValues(cursor, Groups._SYNC_ID, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups._SYNC_TIME, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups._SYNC_VERSION, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups._SYNC_ACCOUNT, mValues);
+            DatabaseUtils.cursorStringToContentValues(cursor, Groups._SYNC_ACCOUNT_TYPE, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups.NAME, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups.NOTES, mValues);
             DatabaseUtils.cursorStringToContentValues(cursor, Groups.SYSTEM_ID, mValues);
@@ -3874,7 +3906,9 @@
                 c.moveToNext();
                 String oldName = c.getString(c.getColumnIndexOrThrow(Groups.NAME));
                 String newName = null;
-                String account = c.getString(c.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT));
+                String accountName = c.getString(c.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT));
+                String accountType = c.getString(
+                        c.getColumnIndexOrThrow(Groups._SYNC_ACCOUNT_TYPE));
                 String syncId = c.getString(c.getColumnIndexOrThrow(Groups._SYNC_ID));
                 String systemId = c.getString(c.getColumnIndexOrThrow(Groups.SYSTEM_ID));
                 if (!TextUtils.isEmpty(systemId)) {
@@ -3887,7 +3921,8 @@
                 }
 
                 // this must come before the delete, since the join won't work once this row is gone
-                fixupPeopleStarredOnGroupRename(oldName, newName, account, syncId);
+                fixupPeopleStarredOnGroupRename(oldName, newName,
+                        new Account(accountName, accountType), syncId);
             } finally {
                 c.close();
             }
@@ -4096,6 +4131,7 @@
     private static final HashMap<String, String> sPresenceProjectionMap;
     private static final HashMap<String, String> sEmailSearchProjectionMap;
     private static final HashMap<String, String> sOrganizationsProjectionMap;
+    private static final HashMap<String, String> sSearchSuggestionsProjectionMap;
     private static final HashMap<String, String> sGroupMembershipProjectionMap;
     private static final HashMap<String, String> sPhotosProjectionMap;
     private static final HashMap<String, String> sExtensionsProjectionMap;
@@ -4132,7 +4168,7 @@
             + "CASE WHEN (phonetic_name IS NOT NULL AND phonetic_name != '') "
                 + "THEN phonetic_name "
             + "ELSE "
-                + "(CASE WHEN (name is NOT NULL AND name != '') "
+                + "(CASE WHEN (name is NOT NULL AND name != '')"
                     + "THEN name "
                 + "ELSE "
                     + "(CASE WHEN primary_email IS NOT NULL THEN "
@@ -4149,9 +4185,6 @@
             + "END"
         + ")";
     
-    private static final String NAME_WHEN_SQL
-            = " WHEN name is NOT NULL ANd name != '' THEN name";
-    
     private static final String PRIMARY_ORGANIZATION_WHEN_SQL
             = " WHEN primary_organization is NOT NULL THEN "
             + "(SELECT company FROM organizations WHERE organizations._id = primary_organization)";
@@ -4205,92 +4238,6 @@
             + " THEN " + Presence.getPresenceIconResourceId(status);
     }
 
-
-    // This is similar to DISPLAY_NAME_SQL. Only difference is that this prioritize
-    // phonetic_name.
-    private static final String PHONETIC_LOOKUP_STRING_SQL = 
-        "GET_NORMALIZED_STRING("
-            + "CASE WHEN (phonetic_name IS NOT NULL AND phonetic_name != '') " 
-                + "THEN phonetic_name "
-            + "ELSE "
-                + "(CASE WHEN (name is NOT NULL AND name != '') "
-                    + "THEN name "
-                + "ELSE "
-                    + "(CASE WHEN primary_organization is NOT NULL THEN "
-                        + "(SELECT company FROM organizations WHERE "
-                            + "organizations._id = primary_organization) "
-                    + "ELSE "
-                        + "(CASE WHEN primary_phone IS NOT NULL THEN "
-                            +"(SELECT number FROM phones WHERE phones._id = primary_phone) "
-                        + "ELSE "
-                            + "(CASE WHEN primary_email IS NOT NULL THEN "
-                                + "(SELECT data FROM contact_methods WHERE "
-                                    + "contact_methods._id = primary_email) "
-                            + "ELSE "
-                                + "null "
-                            + "END) "
-                        + "END) "
-                    + "END) "
-                + "END) "
-            + "END)";
-
-    private static final String PHONETIC_SUGGEST_DESCRIPTION_SQL =
-        "(CASE"
-            + " WHEN (phonetic_name IS NOT NULL AND phonetic_name != '') THEN "
-            // PHONETIC_LOOKUP_STRING_SQL returns phonetic_name. try name, org, phone, email
-                + "(CASE"
-                    + NAME_WHEN_SQL
-                    + PRIMARY_ORGANIZATION_WHEN_SQL
-                    + PRIMARY_PHONE_WHEN_SQL
-                    + PRIMARY_EMAIL_WHEN_SQL
-                    + " ELSE null END)"
-            // PHONETIC_LOOKUP_STRING_SQL returns name, try org, phone, email
-            + " WHEN (name IS NOT NULL AND name != '') THEN "
-                + "(CASE"
-                    + PRIMARY_ORGANIZATION_WHEN_SQL
-                    + PRIMARY_PHONE_WHEN_SQL
-                    + PRIMARY_EMAIL_WHEN_SQL
-                    + " ELSE null END)"
-            // PHONETIC_LOOKUP_STRING_SQL returns org, try phone, email
-            + " WHEN primary_organization is NOT NULL THEN "
-                + "(CASE"
-                    + PRIMARY_PHONE_WHEN_SQL
-                    + PRIMARY_EMAIL_WHEN_SQL
-                    + " ELSE null END)"
-            // PHONETIC_LOOKUP_STRING_SQL returns phone, try email
-            + " WHEN primary_phone IS NOT NULL THEN "
-                + "(CASE"
-                    + PRIMARY_EMAIL_WHEN_SQL
-                    + " ELSE null END)"
-            // PHONETIC_LOOKUP_STRING_SQL returns email or NULL, return NULL
-        + " ELSE null END)";
-    
-    // "primary_organization" etc. are not considered here, since peopleLookup does not
-    // consider them either.
-    private static final String PHONETIC_LOOKUP_SQL_SIMPLE =
-        "GET_NORMALIZED_STRING("
-            + "CASE WHEN (phonetic_name IS NOT NULL AND phonetic_name != '') "
-                + "THEN phonetic_name "
-            + "ELSE "
-                + "(CASE WHEN (name is NOT NULL AND name != '') "
-                    + "THEN name "
-                + "ELSE "
-                    + "'' "
-                + "END) "
-            + "END)";
-    
-    private static final String PHONETIC_LOOKUP_SQL_SIMPLE_WITH_NEW =
-        "GET_NORMALIZED_STRING("
-            + "CASE WHEN (new.phonetic_name IS NOT NULL AND new.phonetic_name != '') "
-                + "THEN new.phonetic_name "
-            + "ELSE "
-                + "(CASE WHEN (new.name is NOT NULL AND new.name != '') "
-                    + "THEN new.name "
-                + "ELSE "
-                    + "'' "
-                + "END) "
-            + "END)";
-    
     private static final String[] sPhonesKeyColumns;
     private static final String[] sContactMethodsKeyColumns;
     private static final String[] sOrganizationsKeyColumns;
@@ -4312,28 +4259,6 @@
         return (sb == null) ? "" : sb.toString();
     }
 
-    /**
-     * @return true when phonetic_name should be considered when looking up people's names.
-     */
-    private synchronized boolean usePhoneticNameForPeopleLookup() {
-        return mSearchSuggestionLanguage.equals(Locale.JAPAN.getLanguage());
-    }
-    
-    private void updateSuggestColumnTexts() {
-        if (usePhoneticNameForPeopleLookup()) {
-            mSearchSuggestionsProjectionMap.put(SearchManager.SUGGEST_COLUMN_TEXT_1,
-                    PHONETIC_LOOKUP_STRING_SQL + " AS " + SearchManager.SUGGEST_COLUMN_TEXT_1);
-            mSearchSuggestionsProjectionMap.put(SearchManager.SUGGEST_COLUMN_TEXT_2,
-                    PHONETIC_SUGGEST_DESCRIPTION_SQL + " AS " + 
-                    SearchManager.SUGGEST_COLUMN_TEXT_2);
-        } else {
-            mSearchSuggestionsProjectionMap.put(SearchManager.SUGGEST_COLUMN_TEXT_1,
-                    DISPLAY_NAME_SQL + " AS " + SearchManager.SUGGEST_COLUMN_TEXT_1);
-            mSearchSuggestionsProjectionMap.put(SearchManager.SUGGEST_COLUMN_TEXT_2,
-                    SUGGEST_DESCRIPTION_SQL + " AS " + SearchManager.SUGGEST_COLUMN_TEXT_2);
-        }
-    }
-    
     static {
         // Contacts URI matching table
         UriMatcher matcher = sURIMatcher;
@@ -4463,6 +4388,7 @@
         syncColumns.put(SyncConstValue._SYNC_LOCAL_ID, SyncConstValue._SYNC_LOCAL_ID);
         syncColumns.put(SyncConstValue._SYNC_DIRTY, SyncConstValue._SYNC_DIRTY);
         syncColumns.put(SyncConstValue._SYNC_ACCOUNT, SyncConstValue._SYNC_ACCOUNT);
+        syncColumns.put(SyncConstValue._SYNC_ACCOUNT_TYPE, SyncConstValue._SYNC_ACCOUNT_TYPE);
 
         // Phones columns
         HashMap<String, String> phonesColumns = new HashMap<String, String>();
@@ -4491,8 +4417,10 @@
         // People with E-mail or IM projection map
         map = new HashMap<String, String>();
         map.put(People._ID, "people._id AS " + People._ID);
-        map.put(ContactMethods.DATA, "contact_methods." + ContactMethods.DATA + " AS " + ContactMethods.DATA);
-        map.put(ContactMethods.KIND, "contact_methods." + ContactMethods.KIND + " AS " + ContactMethods.KIND);
+        map.put(ContactMethods.DATA,
+                "contact_methods." + ContactMethods.DATA + " AS " + ContactMethods.DATA);
+        map.put(ContactMethods.KIND,
+                "contact_methods." + ContactMethods.KIND + " AS " + ContactMethods.KIND);
         map.putAll(peopleColumns);
         sPeopleWithEmailOrImProjectionMap = map;
         
@@ -4509,6 +4437,7 @@
         map.put(GroupMembership.PERSON_ID, GroupMembership.PERSON_ID);
         map.put(GroupMembership.GROUP_ID, "groups._id AS " + GroupMembership.GROUP_ID);
         map.put(GroupMembership.GROUP_SYNC_ACCOUNT, GroupMembership.GROUP_SYNC_ACCOUNT);
+        map.put(GroupMembership.GROUP_SYNC_ACCOUNT_TYPE, GroupMembership.GROUP_SYNC_ACCOUNT_TYPE);
         map.put(GroupMembership.GROUP_SYNC_ID, GroupMembership.GROUP_SYNC_ID);
         map.putAll(groupsColumns);
         sGroupMembershipProjectionMap = map;
@@ -4597,6 +4526,27 @@
         map.putAll(peopleColumns);
         sPresenceProjectionMap = map;
 
+        // Search suggestions projection map
+        map = new HashMap<String, String>();
+        map.put(SearchManager.SUGGEST_COLUMN_TEXT_1,
+                DISPLAY_NAME_SQL + " AS " + SearchManager.SUGGEST_COLUMN_TEXT_1);
+        map.put(SearchManager.SUGGEST_COLUMN_TEXT_2,
+                SUGGEST_DESCRIPTION_SQL + " AS " + SearchManager.SUGGEST_COLUMN_TEXT_2);
+        map.put(SearchManager.SUGGEST_COLUMN_ICON_1,
+                "(CASE WHEN " + Photos.DATA + " IS NOT NULL"
+                + " THEN '" + People.CONTENT_URI + "/' || people._id ||"
+                        + " '/" + Photos.CONTENT_DIRECTORY + "/data'"
+                + " ELSE " + com.android.internal.R.drawable.ic_contact_picture
+                + " END) AS " + SearchManager.SUGGEST_COLUMN_ICON_1);
+        map.put(SearchManager.SUGGEST_COLUMN_ICON_2,
+                PRESENCE_ICON_SQL + " AS " + SearchManager.SUGGEST_COLUMN_ICON_2);
+        map.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID,
+                "people._id AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID);
+        map.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
+                "people._id AS " + SearchManager.SUGGEST_COLUMN_SHORTCUT_ID);
+        map.put(People._ID, "people._id AS " + People._ID);
+        sSearchSuggestionsProjectionMap = map;
+
         // Photos projection map
         map = new HashMap<String, String>();
         map.put(Photos._ID, Photos._ID);
@@ -4628,18 +4578,22 @@
                 ContactMethods.DATA, ContactMethods.KIND);
         sOrganizationsKeyOrderBy = buildOrderBy(sOrganizationsTable, Organizations.COMPANY);
         sGroupmembershipKeyOrderBy =
-                buildOrderBy(sGroupmembershipTable, GroupMembership.GROUP_SYNC_ACCOUNT);
+                buildOrderBy(sGroupmembershipTable, GroupMembership.GROUP_SYNC_ACCOUNT_TYPE,
+                        GroupMembership.GROUP_SYNC_ACCOUNT);
 
         sPhonesKeyColumns = new String[]{Phones.NUMBER};
         sContactMethodsKeyColumns = new String[]{ContactMethods.DATA, ContactMethods.KIND};
         sOrganizationsKeyColumns = new String[]{Organizations.COMPANY};
-        sGroupmembershipKeyColumns = new String[]{GroupMembership.GROUP_SYNC_ACCOUNT};
+        sGroupmembershipKeyColumns = new String[]{GroupMembership.GROUP_SYNC_ACCOUNT,
+                GroupMembership.GROUP_SYNC_ACCOUNT_TYPE};
         sExtensionsKeyColumns = new String[]{Extensions.NAME};
 
         String groupJoinByLocalId = "groups._id=groupmembership.group_id";
         String groupJoinByServerId = "("
                 + "groups._sync_account=groupmembership.group_sync_account"
                 + " AND "
+                + "groups._sync_account_type=groupmembership.group_sync_account_type"
+                + " AND "
                 + "groups._sync_id=groupmembership.group_sync_id"
                 + ")";
         sGroupsJoinString = "(" + groupJoinByLocalId + " OR " + groupJoinByServerId + ")";
diff --git a/src/com/android/providers/contacts/ContactsProvider2.java b/src/com/android/providers/contacts/ContactsProvider2.java
new file mode 100644
index 0000000..d8af3da
--- /dev/null
+++ b/src/com/android/providers/contacts/ContactsProvider2.java
@@ -0,0 +1,2905 @@
+/*
+ * 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.content.SyncStateContentProviderHelper;
+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.DataColumns;
+import com.android.providers.contacts.OpenHelper.GroupsColumns;
+import com.android.providers.contacts.OpenHelper.MimetypesColumns;
+import com.android.providers.contacts.OpenHelper.PackagesColumns;
+import com.android.providers.contacts.OpenHelper.PhoneColumns;
+import com.android.providers.contacts.OpenHelper.PhoneLookupColumns;
+import com.android.providers.contacts.OpenHelper.RawContactsColumns;
+import com.android.providers.contacts.OpenHelper.Tables;
+import com.google.android.collect.Lists;
+
+import android.accounts.Account;
+import android.app.SearchManager;
+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.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.RawContacts;
+import android.provider.ContactsContract.CommonDataKinds.BaseTypes;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.Contacts.AggregationSuggestions;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+
+/**
+ * Contacts content provider. The contract between this provider and applications
+ * is defined in {@link ContactsContract}.
+ */
+public class ContactsProvider2 extends ContentProvider {
+    // TODO: clean up debug tag and rename this class
+    private static final String TAG = "ContactsProvider ~~~~";
+
+    // TODO: carefully prevent all incoming nested queries; they can be gaping security holes
+    // TODO: check for restricted flag during insert(), update(), and delete() calls
+
+    /** Default for the maximum number of returned aggregation suggestions. */
+    private static final int DEFAULT_MAX_SUGGESTIONS = 5;
+
+    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+
+    private static final String STREQUENT_ORDER_BY = Contacts.STARRED + " DESC, "
+            + Contacts.TIMES_CONTACTED + " DESC, "
+            + Contacts.DISPLAY_NAME + " ASC";
+    private static final String STREQUENT_LIMIT =
+            "(SELECT COUNT(1) FROM " + Tables.CONTACTS + " WHERE "
+            + Contacts.STARRED + "=1) + 25";
+
+    private static final int CONTACTS = 1000;
+    private static final int CONTACTS_ID = 1001;
+    private static final int CONTACTS_DATA = 1002;
+    private static final int CONTACTS_SUMMARY = 1003;
+    private static final int CONTACTS_RAW_CONTACTS = 1004;
+    private static final int CONTACTS_SUMMARY_ID = 1005;
+    private static final int CONTACTS_SUMMARY_FILTER = 1006;
+    private static final int CONTACTS_SUMMARY_STREQUENT = 1007;
+    private static final int CONTACTS_SUMMARY_STREQUENT_FILTER = 1008;
+    private static final int CONTACTS_SUMMARY_GROUP = 1009;
+
+    private static final int RAW_CONTACTS = 2002;
+    private static final int RAW_CONTACTS_ID = 2003;
+    private static final int RAW_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 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 GROUPS = 10000;
+    private static final int GROUPS_ID = 10001;
+    private static final int GROUPS_SUMMARY = 10003;
+
+    private static final int SYNCSTATE = 11000;
+
+    private static final int SEARCH_SUGGESTIONS = 12001;
+    private static final int SEARCH_SHORTCUT = 12002;
+
+    private interface ContactsQuery {
+        public static final String TABLE = Tables.RAW_CONTACTS;
+
+        public static final String[] PROJECTION = new String[] {
+            RawContactsColumns.CONCRETE_ID,
+            RawContacts.ACCOUNT_NAME,
+            RawContacts.ACCOUNT_TYPE,
+        };
+
+        public static final int RAW_CONTACT_ID = 0;
+        public static final int ACCOUNT_NAME = 1;
+        public static final int ACCOUNT_TYPE = 2;
+    }
+
+    private interface DataRawContactsQuery {
+        public static final String TABLE = Tables.DATA_JOIN_MIMETYPE_RAW_CONTACTS;
+
+        public static final String[] PROJECTION = new String[] {
+            RawContactsColumns.CONCRETE_ID,
+            DataColumns.CONCRETE_ID,
+            RawContacts.CONTACT_ID,
+            RawContacts.IS_RESTRICTED,
+            Data.MIMETYPE,
+        };
+
+        public static final int RAW_CONTACT_ID = 0;
+        public static final int DATA_ID = 1;
+        public static final int CONTACT_ID = 2;
+        public static final int IS_RESTRICTED = 3;
+        public static final int MIMETYPE = 4;
+    }
+
+    private interface DataContactsQuery {
+        public static final String TABLE = Tables.DATA_JOIN_MIMETYPES_RAW_CONTACTS_CONTACTS;
+
+        public static final String[] PROJECTION = new String[] {
+            RawContactsColumns.CONCRETE_ID,
+            DataColumns.CONCRETE_ID,
+            ContactsColumns.CONCRETE_ID,
+            MimetypesColumns.CONCRETE_ID,
+            Phone.NUMBER,
+            Email.DATA,
+            ContactsColumns.OPTIMAL_PRIMARY_PHONE_ID,
+            ContactsColumns.FALLBACK_PRIMARY_PHONE_ID,
+            ContactsColumns.OPTIMAL_PRIMARY_EMAIL_ID,
+            ContactsColumns.FALLBACK_PRIMARY_EMAIL_ID,
+        };
+
+        public static final int RAW_CONTACT_ID = 0;
+        public static final int DATA_ID = 1;
+        public static final int CONTACT_ID = 2;
+        public static final int MIMETYPE_ID = 3;
+        public static final int PHONE_NUMBER = 4;
+        public static final int EMAIL_DATA = 5;
+        public static final int OPTIMAL_PHONE_ID = 6;
+        public static final int FALLBACK_PHONE_ID = 7;
+        public static final int OPTIMAL_EMAIL_ID = 8;
+        public static final int FALLBACK_EMAIL_ID = 9;
+
+    }
+
+    private interface DisplayNameQuery {
+        public static final String TABLE = Tables.DATA_JOIN_MIMETYPES;
+
+        public static final String[] COLUMNS = new String[] {
+            MimetypesColumns.MIMETYPE,
+            Data.IS_PRIMARY,
+            Data.DATA2,
+            StructuredName.DISPLAY_NAME,
+        };
+
+        public static final int MIMETYPE = 0;
+        public static final int IS_PRIMARY = 1;
+        public static final int DATA2 = 2;
+        public static final int DISPLAY_NAME = 3;
+    }
+
+    private interface DataQuery {
+        public static final String TABLE = Tables.DATA_JOIN_MIMETYPES;
+
+        public static final String[] COLUMNS = new String[] {
+            DataColumns.CONCRETE_ID,
+            MimetypesColumns.MIMETYPE,
+            Data.RAW_CONTACT_ID,
+            Data.IS_PRIMARY,
+            Data.DATA1,
+            Data.DATA2,
+            Data.DATA3,
+            Data.DATA4,
+            Data.DATA5,
+            Data.DATA6,
+            Data.DATA7,
+            Data.DATA8,
+            Data.DATA9,
+            Data.DATA10,
+            Data.DATA11,
+            Data.DATA12,
+            Data.DATA13,
+            Data.DATA14,
+            Data.DATA15,
+        };
+
+        public static final int ID = 0;
+        public static final int MIMETYPE = 1;
+        public static final int RAW_CONTACT_ID = 2;
+        public static final int IS_PRIMARY = 3;
+        public static final int DATA1 = 4;
+        public static final int DATA2 = 5;
+        public static final int DATA3 = 6;
+        public static final int DATA4 = 7;
+        public static final int DATA5 = 8;
+        public static final int DATA6 = 9;
+        public static final int DATA7 = 10;
+        public static final int DATA8 = 11;
+        public static final int DATA9 = 12;
+        public static final int DATA10 = 13;
+        public static final int DATA11 = 14;
+        public static final int DATA12 = 15;
+        public static final int DATA13 = 16;
+        public static final int DATA14 = 17;
+        public static final int DATA15 = 18;
+    }
+
+    private interface DataIdQuery {
+        String[] COLUMNS = { Data._ID };
+
+        int _ID = 0;
+    }
+
+    // Higher number represents higher priority in choosing what data to use for the display name
+    private static final int DISPLAY_NAME_PRIORITY_EMAIL = 1;
+    private static final int DISPLAY_NAME_PRIORITY_PHONE = 2;
+    private static final int DISPLAY_NAME_PRIORITY_ORGANIZATION = 3;
+    private static final int DISPLAY_NAME_PRIORITY_STRUCTURED_NAME = 4;
+
+    private static final HashMap<String, Integer> sDisplayNamePriorities;
+    static {
+        sDisplayNamePriorities = new HashMap<String, Integer>();
+        sDisplayNamePriorities.put(StructuredName.CONTENT_ITEM_TYPE,
+                DISPLAY_NAME_PRIORITY_STRUCTURED_NAME);
+        sDisplayNamePriorities.put(Organization.CONTENT_ITEM_TYPE,
+                DISPLAY_NAME_PRIORITY_ORGANIZATION);
+        sDisplayNamePriorities.put(Phone.CONTENT_ITEM_TYPE,
+                DISPLAY_NAME_PRIORITY_PHONE);
+        sDisplayNamePriorities.put(Email.CONTENT_ITEM_TYPE,
+                DISPLAY_NAME_PRIORITY_EMAIL);
+    }
+
+    /** Contains just the contacts columns */
+    private static final HashMap<String, String> sContactsProjectionMap;
+    /** Contains the contact columns along with primary phone */
+    private static final HashMap<String, String> sContactsSummaryProjectionMap;
+    /** Contains the data, contacts, and contact columns, for joined tables. */
+    private static final HashMap<String, String> sDataRawContactsContactProjectionMap;
+    /** Contains the data, contacts, group sourceid and contact columns, for joined tables. */
+    private static final HashMap<String, String> sDataRawContactsGroupsContactProjectionMap;
+    /** Contains the contacts, and raw contact columns, for joined tables. */
+    private static final HashMap<String, String> sRawContactsContactsProjectionMap;
+    /** Contains just the contacts columns */
+    private static final HashMap<String, String> sRawContactsProjectionMap;
+    /** Contains just the data columns */
+    private static final HashMap<String, String> sDataGroupsProjectionMap;
+    /** Contains the data and contacts columns, for joined tables */
+    private static final HashMap<String, String> sDataRawContactsGroupsProjectionMap;
+    /** Contains the data and contacts columns, for joined tables */
+    private static final HashMap<String, String> sDataRawContactsProjectionMap;
+    /** 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 agg_exceptions columns */
+    private static final HashMap<String, String> sAggregationExceptionsProjectionMap;
+    /** Contains Presence columns */
+    private static final HashMap<String, String> sPresenceProjectionMap;
+
+    /** Sql select statement that returns the contact id associated with a data record. */
+    private static final String sNestedRawContactIdSelect;
+    /** Sql select statement that returns the mimetype id associated with a data record. */
+    private static final String sNestedMimetypeSelect;
+    /** Sql select statement that returns the contact id associated with a contact record. */
+    private static final String sNestedContactIdSelect;
+    /** Sql select statement that returns a list of contact ids associated with an contact 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;
+    /** Sql where statement for filtering on groups. */
+    private static final String sContactsInGroupSelect;
+    /** Precompiled sql statement for setting a data record to the primary. */
+    private SQLiteStatement mSetPrimaryStatement;
+    /** Precompiled sql statement for setting a data record to the super primary. */
+    private SQLiteStatement mSetSuperPrimaryStatement;
+    /** Precompiled sql statement for incrementing times contacted for an contact */
+    private SQLiteStatement mLastTimeContactedUpdate;
+    /** Precompiled sql statement for updating a contact display name */
+    private SQLiteStatement mContactDisplayNameUpdate;
+
+    static {
+        // Contacts URI matching table
+        final UriMatcher matcher = sUriMatcher;
+        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/#/raw_contacts", CONTACTS_RAW_CONTACTS);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts_summary", CONTACTS_SUMMARY);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts_summary/#", CONTACTS_SUMMARY_ID);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts_summary/filter/*",
+                CONTACTS_SUMMARY_FILTER);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts_summary/strequent/",
+                CONTACTS_SUMMARY_STREQUENT);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts_summary/strequent/filter/*",
+                CONTACTS_SUMMARY_STREQUENT_FILTER);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts_summary/group/*",
+                CONTACTS_SUMMARY_GROUP);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts/#/suggestions",
+                AGGREGATION_SUGGESTIONS);
+        matcher.addURI(ContactsContract.AUTHORITY, "raw_contacts", RAW_CONTACTS);
+        matcher.addURI(ContactsContract.AUTHORITY, "raw_contacts/#", RAW_CONTACTS_ID);
+        matcher.addURI(ContactsContract.AUTHORITY, "raw_contacts/#/data", RAW_CONTACTS_DATA);
+        matcher.addURI(ContactsContract.AUTHORITY, "raw_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, SyncStateContentProviderHelper.PATH, SYNCSTATE);
+
+        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, SearchManager.SUGGEST_URI_PATH_QUERY,
+                SEARCH_SUGGESTIONS);
+        matcher.addURI(ContactsContract.AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY + "/*",
+                SEARCH_SUGGESTIONS);
+        matcher.addURI(ContactsContract.AUTHORITY, SearchManager.SUGGEST_URI_PATH_SHORTCUT + "/#",
+                SEARCH_SHORTCUT);
+
+        HashMap<String, String> columns;
+
+        // Contacts projection map
+        columns = new HashMap<String, String>();
+        columns.put(Contacts._ID, "contacts._id AS _id");
+        columns.put(Contacts.DISPLAY_NAME, ContactsColumns.CONCRETE_DISPLAY_NAME + " AS "
+                + Contacts.DISPLAY_NAME);
+        columns.put(Contacts.LAST_TIME_CONTACTED, ContactsColumns.CONCRETE_LAST_TIME_CONTACTED
+                + " AS " + Contacts.LAST_TIME_CONTACTED);
+        columns.put(Contacts.TIMES_CONTACTED, ContactsColumns.CONCRETE_TIMES_CONTACTED + " AS "
+                + Contacts.TIMES_CONTACTED);
+        columns.put(Contacts.STARRED, ContactsColumns.CONCRETE_STARRED + " AS "
+                + Contacts.STARRED);
+        columns.put(Contacts.IN_VISIBLE_GROUP, Contacts.IN_VISIBLE_GROUP);
+        columns.put(Contacts.PHOTO_ID, Contacts.PHOTO_ID);
+        columns.put(Contacts.PRIMARY_PHONE_ID, Contacts.PRIMARY_PHONE_ID);
+        columns.put(Contacts.PRIMARY_EMAIL_ID, Contacts.PRIMARY_EMAIL_ID);
+        columns.put(Contacts.CUSTOM_RINGTONE, ContactsColumns.CONCRETE_CUSTOM_RINGTONE + " AS "
+                + Contacts.CUSTOM_RINGTONE);
+        columns.put(Contacts.SEND_TO_VOICEMAIL, ContactsColumns.CONCRETE_SEND_TO_VOICEMAIL
+                + " AS " + Contacts.SEND_TO_VOICEMAIL);
+        columns.put(ContactsColumns.FALLBACK_PRIMARY_PHONE_ID,
+                ContactsColumns.FALLBACK_PRIMARY_PHONE_ID);
+        columns.put(ContactsColumns.FALLBACK_PRIMARY_EMAIL_ID,
+                ContactsColumns.FALLBACK_PRIMARY_EMAIL_ID);
+        sContactsProjectionMap = columns;
+
+        columns = new HashMap<String, String>();
+        columns.putAll(sContactsProjectionMap);
+
+        // Contacts primaries projection map. The overall presence status is
+        // the most-present value, as indicated by the largest value.
+        columns.put(Contacts.PRESENCE_STATUS, "MAX(" + Presence.PRESENCE_STATUS + ")");
+        columns.put(Contacts.PRIMARY_PHONE_TYPE, CommonDataKinds.Phone.TYPE);
+        columns.put(Contacts.PRIMARY_PHONE_LABEL, CommonDataKinds.Phone.LABEL);
+        columns.put(Contacts.PRIMARY_PHONE_NUMBER, CommonDataKinds.Phone.NUMBER);
+        sContactsSummaryProjectionMap = columns;
+
+        // RawContacts projection map
+        columns = new HashMap<String, String>();
+        columns.put(RawContacts._ID, Tables.RAW_CONTACTS + "." + RawContacts._ID + " AS _id");
+        columns.put(RawContacts.CONTACT_ID, RawContacts.CONTACT_ID);
+        columns.put(RawContacts.ACCOUNT_NAME,
+                OpenHelper.RawContactsColumns.CONCRETE_ACCOUNT_NAME
+                        + " AS " + RawContacts.ACCOUNT_NAME);
+        columns.put(RawContacts.ACCOUNT_TYPE,
+                OpenHelper.RawContactsColumns.CONCRETE_ACCOUNT_TYPE
+                        + " AS " + RawContacts.ACCOUNT_TYPE);
+        columns.put(RawContacts.SOURCE_ID,
+                OpenHelper.RawContactsColumns.CONCRETE_SOURCE_ID
+                        + " AS " + RawContacts.SOURCE_ID);
+        columns.put(RawContacts.VERSION,
+                OpenHelper.RawContactsColumns.CONCRETE_VERSION
+                        + " AS " + RawContacts.VERSION);
+        columns.put(RawContacts.DIRTY,
+                OpenHelper.RawContactsColumns.CONCRETE_DIRTY
+                        + " AS " + RawContacts.DIRTY);
+        columns.put(RawContacts.DELETED,
+                OpenHelper.RawContactsColumns.CONCRETE_DELETED
+                        + " AS " + RawContacts.DELETED);
+        sRawContactsProjectionMap = columns;
+
+        columns = new HashMap<String, String>();
+        columns.putAll(sContactsProjectionMap);
+        columns.putAll(sRawContactsProjectionMap);
+        sRawContactsContactsProjectionMap = columns;
+
+        // Data projection map
+        columns = new HashMap<String, String>();
+        columns.put(Data._ID, Tables.DATA + "." + Data._ID + " AS _id");
+        columns.put(Data.RAW_CONTACT_ID, Data.RAW_CONTACT_ID);
+        columns.put(Data.RES_PACKAGE, PackagesColumns.PACKAGE + " AS " + Data.RES_PACKAGE);
+        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");
+        columns.put(Data.DATA11, "data.data11 as data11");
+        columns.put(Data.DATA12, "data.data12 as data12");
+        columns.put(Data.DATA13, "data.data13 as data13");
+        columns.put(Data.DATA14, "data.data14 as data14");
+        columns.put(Data.DATA15, "data.data15 as data15");
+        columns.put(GroupMembership.GROUP_SOURCE_ID, GroupsColumns.CONCRETE_SOURCE_ID + " AS "
+                + GroupMembership.GROUP_SOURCE_ID);
+
+        // TODO: remove this projection
+        // Mappings used for backwards compatibility.
+        columns.put("number", Phone.NUMBER);
+        sDataGroupsProjectionMap = columns;
+
+        // Data, groups and contacts projection map for joins. _id comes from the data table
+        columns = new HashMap<String, String>();
+        columns.putAll(sRawContactsProjectionMap);
+        columns.putAll(sDataGroupsProjectionMap); // _id will be replaced with the one from data
+        columns.put(Data.RAW_CONTACT_ID, DataColumns.CONCRETE_RAW_CONTACT_ID);
+        sDataRawContactsGroupsProjectionMap = columns;
+
+        // Data and contacts projection map for joins. _id comes from the data table
+        columns = new HashMap<String, String>();
+        columns.putAll(sDataRawContactsGroupsProjectionMap);
+        columns.remove(GroupMembership.GROUP_SOURCE_ID);
+        sDataRawContactsProjectionMap = columns;
+
+        // Data and contacts projection map for joins. _id comes from the data table
+        columns = new HashMap<String, String>();
+        columns.putAll(sContactsProjectionMap);
+        columns.putAll(sRawContactsProjectionMap); //
+        columns.putAll(sDataGroupsProjectionMap); // _id will be replaced with the one from data
+        columns.put(Data.RAW_CONTACT_ID, DataColumns.CONCRETE_RAW_CONTACT_ID);
+        sDataRawContactsGroupsContactProjectionMap = columns;
+
+        // Data and contacts projection map for joins. _id comes from the data table
+        columns = new HashMap<String, String>();
+        columns.putAll(sDataRawContactsGroupsContactProjectionMap);
+        columns.remove(GroupMembership.GROUP_SOURCE_ID);
+        sDataRawContactsContactProjectionMap = columns;
+
+        // Groups projection map
+        columns = new HashMap<String, String>();
+        columns.put(Groups._ID, "groups._id AS _id");
+        columns.put(Groups.ACCOUNT_NAME, Groups.ACCOUNT_NAME);
+        columns.put(Groups.ACCOUNT_TYPE, Groups.ACCOUNT_TYPE);
+        columns.put(Groups.SOURCE_ID, Groups.SOURCE_ID);
+        columns.put(Groups.DIRTY, Groups.DIRTY);
+        columns.put(Groups.VERSION, Groups.VERSION);
+        columns.put(Groups.RES_PACKAGE, PackagesColumns.PACKAGE + " AS " + Groups.RES_PACKAGE);
+        columns.put(Groups.TITLE, Groups.TITLE);
+        columns.put(Groups.TITLE_RES, Groups.TITLE_RES);
+        columns.put(Groups.GROUP_VISIBLE, Groups.GROUP_VISIBLE);
+        sGroupsProjectionMap = columns;
+
+        // RawContacts and groups projection map
+        columns = new HashMap<String, String>();
+        columns.putAll(sGroupsProjectionMap);
+
+        columns.put(Groups.SUMMARY_COUNT, "(SELECT COUNT(DISTINCT " + ContactsColumns.CONCRETE_ID
+                + ") FROM " + Tables.DATA_JOIN_MIMETYPES_RAW_CONTACTS_CONTACTS + " WHERE "
+                + Clauses.MIMETYPE_IS_GROUP_MEMBERSHIP + " AND " + Clauses.BELONGS_TO_GROUP
+                + ") AS " + Groups.SUMMARY_COUNT);
+
+        columns.put(Groups.SUMMARY_WITH_PHONES, "(SELECT COUNT(DISTINCT "
+                + ContactsColumns.CONCRETE_ID + ") FROM "
+                + Tables.DATA_JOIN_MIMETYPES_RAW_CONTACTS_CONTACTS + " 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.CONTACT_ID,
+                "raw_contacts1." + RawContacts.CONTACT_ID
+                + " AS " + AggregationExceptions.CONTACT_ID);
+        columns.put(AggregationExceptions.RAW_CONTACT_ID, AggregationExceptionColumns.RAW_CONTACT_ID2);
+        sAggregationExceptionsProjectionMap = columns;
+
+
+        columns = new HashMap<String, String>();
+        columns.put(Presence._ID, Presence._ID);
+        columns.put(Presence.RAW_CONTACT_ID, Presence.RAW_CONTACT_ID);
+        columns.put(Presence.DATA_ID, Presence.DATA_ID);
+        columns.put(Presence.IM_ACCOUNT, Presence.IM_ACCOUNT);
+        columns.put(Presence.IM_HANDLE, Presence.IM_HANDLE);
+        columns.put(Presence.IM_PROTOCOL, Presence.IM_PROTOCOL);
+        columns.put(Presence.PRESENCE_STATUS, Presence.PRESENCE_STATUS);
+        columns.put(Presence.PRESENCE_CUSTOM_STATUS, Presence.PRESENCE_CUSTOM_STATUS);
+        sPresenceProjectionMap = columns;
+
+        sNestedRawContactIdSelect = "SELECT " + Data.RAW_CONTACT_ID + " FROM " + Tables.DATA + " WHERE "
+                + Data._ID + "=?";
+        sNestedMimetypeSelect = "SELECT " + DataColumns.MIMETYPE_ID + " FROM " + Tables.DATA
+                + " WHERE " + Data._ID + "=?";
+        sNestedContactIdSelect = "SELECT " + RawContacts.CONTACT_ID + " FROM " + Tables.RAW_CONTACTS
+                + " WHERE " + RawContacts._ID + "=(" + sNestedRawContactIdSelect + ")";
+        sNestedContactIdListSelect = "SELECT " + RawContacts._ID + " FROM " + Tables.RAW_CONTACTS
+                + " WHERE " + RawContacts.CONTACT_ID + "=(" + sNestedContactIdSelect + ")";
+        sSetPrimaryWhere = Data.RAW_CONTACT_ID + "=(" + sNestedRawContactIdSelect + ") AND "
+                + DataColumns.MIMETYPE_ID + "=(" + sNestedMimetypeSelect + ")";
+        sSetSuperPrimaryWhere = Data.RAW_CONTACT_ID + " IN (" + sNestedContactIdListSelect + ") AND "
+                + DataColumns.MIMETYPE_ID + "=(" + sNestedMimetypeSelect + ")";
+        sContactsInGroupSelect = ContactsColumns.CONCRETE_ID + " IN (SELECT "
+                + RawContacts.CONTACT_ID + " FROM " + Tables.RAW_CONTACTS + " WHERE ("
+                + RawContactsColumns.CONCRETE_ID + " IN (SELECT " + Tables.DATA + "."
+                + Data.RAW_CONTACT_ID + " FROM " + Tables.DATA_JOIN_MIMETYPES + " WHERE ("
+                + Data.MIMETYPE + "='" + GroupMembership.CONTENT_ITEM_TYPE + "' AND "
+                + GroupMembership.GROUP_ROW_ID + "=(SELECT " + Tables.GROUPS + "."
+                + Groups._ID + " FROM " + Tables.GROUPS + " WHERE " + Groups.TITLE + "=?)))))";
+    }
+
+    /**
+     * Handles inserts and update for a specific Data type.
+     */
+    private abstract class DataRowHandler {
+
+        protected final String mMimetype;
+
+        public DataRowHandler(String mimetype) {
+            mMimetype = mimetype;
+        }
+
+        /**
+         * Inserts a row into the {@link Data} table.
+         */
+        public long insert(SQLiteDatabase db, long rawContactId, ContentValues values) {
+            final long dataId = db.insert(Tables.DATA, null, values);
+
+            Integer primary = values.getAsInteger(Data.IS_PRIMARY);
+            if (primary != null && primary != 0) {
+                setIsPrimary(dataId);
+            }
+
+            fixContactDisplayName(db, rawContactId);
+            return dataId;
+        }
+
+        /**
+         * Validates data and updates a {@link Data} row using the cursor, which contains
+         * the current data.
+         */
+        public void update(SQLiteDatabase db, ContentValues values, Cursor cursor) {
+            throw new UnsupportedOperationException();
+        }
+
+        public int delete(SQLiteDatabase db, Cursor c) {
+            long dataId = c.getLong(DataQuery.ID);
+            long rawContactId = c.getLong(DataQuery.RAW_CONTACT_ID);
+            boolean primary = c.getInt(DataQuery.IS_PRIMARY) != 0;
+            int count = db.delete(Tables.DATA, Data._ID + "=" + dataId, null);
+            if (count != 0 && primary) {
+                fixPrimary(db, rawContactId);
+                fixContactDisplayName(db, rawContactId);
+            }
+            return count;
+        }
+
+        private void fixPrimary(SQLiteDatabase db, long rawContactId) {
+            long newPrimaryId = findNewPrimaryDataId(db, rawContactId);
+            if (newPrimaryId != -1) {
+                ContactsProvider2.this.setIsPrimary(newPrimaryId);
+            }
+        }
+
+        protected long findNewPrimaryDataId(SQLiteDatabase db, long rawContactId) {
+            long primaryId = -1;
+            int primaryType = -1;
+            Cursor c = queryData(db, rawContactId);
+            try {
+                while (c.moveToNext()) {
+                    long dataId = c.getLong(DataQuery.ID);
+                    int type = c.getInt(DataQuery.DATA2);
+                    if (primaryType == -1 || getTypeRank(type) < getTypeRank(primaryType)) {
+                        primaryId = dataId;
+                        primaryType = type;
+                    }
+                }
+            } finally {
+                c.close();
+            }
+            return primaryId;
+        }
+
+        /**
+         * Returns the rank of a specific record type to be used in determining the primary
+         * row. Lower number represents higher priority.
+         */
+        protected int getTypeRank(int type) {
+            return 0;
+        }
+
+        protected Cursor queryData(SQLiteDatabase db, long rawContactId) {
+            // TODO Lookup integer mimetype IDs' instead of joining for speed
+            return db.query(DataQuery.TABLE, DataQuery.COLUMNS, Data.RAW_CONTACT_ID + "="
+                    + rawContactId + " AND " + MimetypesColumns.MIMETYPE + "='" + mMimetype + "'",
+                    null, null, null, null);
+        }
+
+        protected void fixContactDisplayName(SQLiteDatabase db, long rawContactId) {
+            if (!sDisplayNamePriorities.containsKey(mMimetype)) {
+                return;
+            }
+
+            String bestDisplayName = null;
+            Cursor c = db.query(DisplayNameQuery.TABLE, DisplayNameQuery.COLUMNS,
+                    Data.RAW_CONTACT_ID + "=" + rawContactId, null, null, null, null);
+            try {
+                int maxPriority = -1;
+                while (c.moveToNext()) {
+                    String mimeType = c.getString(DisplayNameQuery.MIMETYPE);
+                    boolean primary;
+                    String name;
+
+                    if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) {
+                        name = c.getString(DisplayNameQuery.DISPLAY_NAME);
+                        primary = true;
+                    } else {
+                        name = c.getString(DisplayNameQuery.DATA2);
+                        primary = (c.getInt(DisplayNameQuery.IS_PRIMARY) != 0);
+                    }
+
+                    if (primary && name != null) {
+                        Integer priority = sDisplayNamePriorities.get(mimeType);
+                        if (priority != null && priority > maxPriority) {
+                            maxPriority = priority;
+                            bestDisplayName = name;
+                        }
+                    }
+                }
+
+            } finally {
+                c.close();
+            }
+
+            ContactsProvider2.this.setDisplayName(rawContactId, bestDisplayName);
+        }
+    }
+
+    public class CustomDataRowHandler extends DataRowHandler {
+
+        public CustomDataRowHandler(String mimetype) {
+            super(mimetype);
+        }
+    }
+
+    public class StructuredNameRowHandler extends DataRowHandler {
+
+        private final NameSplitter mNameSplitter;
+
+        public StructuredNameRowHandler(NameSplitter nameSplitter) {
+            super(StructuredName.CONTENT_ITEM_TYPE);
+            mNameSplitter = nameSplitter;
+        }
+
+        @Override
+        public long insert(SQLiteDatabase db, long rawContactId, ContentValues values) {
+            fixStructuredNameComponents(values);
+            return super.insert(db, rawContactId, values);
+        }
+
+        @Override
+        public void update(SQLiteDatabase db, ContentValues values, Cursor cursor) {
+            // TODO Parse the full name if it has changed and replace pre-existing piece parts.
+        }
+
+        /**
+         * Parses the supplied display name, but only if the incoming values do not already contain
+         * structured name parts.  Also, if the display name is not provided, generate one by
+         * concatenating first name and last name
+         *
+         * TODO see if the order of first and last names needs to be conditionally reversed for
+         * some locales, e.g. China.
+         */
+        private void fixStructuredNameComponents(ContentValues values) {
+            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))) {
+                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());
+            }
+
+            if (TextUtils.isEmpty(fullName)) {
+                String givenName = values.getAsString(StructuredName.GIVEN_NAME);
+                String familyName = values.getAsString(StructuredName.FAMILY_NAME);
+                if (TextUtils.isEmpty(givenName)) {
+                    fullName = familyName;
+                } else if (TextUtils.isEmpty(familyName)) {
+                    fullName = givenName;
+                } else {
+                    fullName = givenName + " " + familyName;
+                }
+
+                if (!TextUtils.isEmpty(fullName)) {
+                    values.put(StructuredName.DISPLAY_NAME, fullName);
+                }
+            }
+        }
+    }
+
+    public class CommonDataRowHandler extends DataRowHandler {
+
+        private final String mTypeColumn;
+        private final String mLabelColumn;
+
+        public CommonDataRowHandler(String mimetype, String typeColumn, String labelColumn) {
+            super(mimetype);
+            mTypeColumn = typeColumn;
+            mLabelColumn = labelColumn;
+        }
+
+        @Override
+        public long insert(SQLiteDatabase db, long rawContactId, ContentValues values) {
+            int type;
+            String label;
+            if (values.containsKey(mTypeColumn)) {
+                type = values.getAsInteger(mTypeColumn);
+            } else {
+                type = BaseTypes.TYPE_CUSTOM;
+            }
+            if (values.containsKey(mLabelColumn)) {
+                label = values.getAsString(mLabelColumn);
+            } else {
+                label = null;
+            }
+
+            if (type != BaseTypes.TYPE_CUSTOM && label != null) {
+                throw new RuntimeException(mLabelColumn + " value can only be specified with "
+                        + mTypeColumn + "=" + BaseTypes.TYPE_CUSTOM + "(custom)");
+            }
+
+            if (type == BaseTypes.TYPE_CUSTOM && label == null) {
+                throw new RuntimeException(mLabelColumn + " value must be specified when "
+                        + mTypeColumn + "=" + BaseTypes.TYPE_CUSTOM + "(custom)");
+            }
+
+            return super.insert(db, rawContactId, values);
+        }
+
+        @Override
+        public void update(SQLiteDatabase db, ContentValues values, Cursor cursor) {
+            // TODO read the data and check the constraint
+        }
+    }
+
+    public class OrganizationDataRowHandler extends CommonDataRowHandler {
+
+        public OrganizationDataRowHandler() {
+            super(Organization.CONTENT_ITEM_TYPE, Organization.TYPE, Organization.LABEL);
+        }
+
+        @Override
+        public long insert(SQLiteDatabase db, long rawContactId, ContentValues values) {
+            long id = super.insert(db, rawContactId, values);
+            fixContactDisplayName(db, rawContactId);
+            return id;
+        }
+
+        @Override
+        protected int getTypeRank(int type) {
+            switch (type) {
+                case Organization.TYPE_WORK: return 0;
+                case Organization.TYPE_CUSTOM: return 1;
+                case Organization.TYPE_OTHER: return 2;
+                default: return 1000;
+            }
+        }
+    }
+
+    public class EmailDataRowHandler extends CommonDataRowHandler {
+
+        public EmailDataRowHandler() {
+            super(Email.CONTENT_ITEM_TYPE, Email.TYPE, Email.LABEL);
+        }
+
+        @Override
+        public long insert(SQLiteDatabase db, long rawContactId, ContentValues values) {
+            long id = super.insert(db, rawContactId, values);
+            fixContactDisplayName(db, rawContactId);
+            return id;
+        }
+
+        @Override
+        protected int getTypeRank(int type) {
+            switch (type) {
+                case Email.TYPE_HOME: return 0;
+                case Email.TYPE_WORK: return 1;
+                case Email.TYPE_CUSTOM: return 2;
+                case Email.TYPE_OTHER: return 3;
+                default: return 1000;
+            }
+        }
+    }
+
+    public class PhoneDataRowHandler extends CommonDataRowHandler {
+
+        public PhoneDataRowHandler() {
+            super(Phone.CONTENT_ITEM_TYPE, Phone.TYPE, Phone.LABEL);
+        }
+
+        @Override
+        public long insert(SQLiteDatabase db, long rawContactId, ContentValues values) {
+            ContentValues phoneValues = new ContentValues();
+            String number = values.getAsString(Phone.NUMBER);
+            String normalizedNumber = null;
+            if (number != null) {
+                normalizedNumber = PhoneNumberUtils.getStrippedReversed(number);
+                values.put(PhoneColumns.NORMALIZED_NUMBER, normalizedNumber);
+            }
+
+            long id = super.insert(db, rawContactId, values);
+
+            if (number != null) {
+                phoneValues.put(PhoneLookupColumns.RAW_CONTACT_ID, rawContactId);
+                phoneValues.put(PhoneLookupColumns.DATA_ID, id);
+                phoneValues.put(PhoneLookupColumns.NORMALIZED_NUMBER, normalizedNumber);
+                db.insert(Tables.PHONE_LOOKUP, null, phoneValues);
+            }
+
+            return id;
+        }
+
+        @Override
+        protected int getTypeRank(int type) {
+            switch (type) {
+                case Phone.TYPE_MOBILE: return 0;
+                case Phone.TYPE_WORK: return 1;
+                case Phone.TYPE_HOME: return 2;
+                case Phone.TYPE_PAGER: return 3;
+                case Phone.TYPE_CUSTOM: return 4;
+                case Phone.TYPE_OTHER: return 5;
+                case Phone.TYPE_FAX_WORK: return 6;
+                case Phone.TYPE_FAX_HOME: return 7;
+                default: return 1000;
+            }
+        }
+    }
+
+    private HashMap<String, DataRowHandler> mDataRowHandlers;
+    private final ContactAggregationScheduler mAggregationScheduler;
+    private OpenHelper mOpenHelper;
+
+    private ContactAggregator mContactAggregator;
+    private NameSplitter mNameSplitter;
+    private LegacyApiSupport mLegacyApiSupport;
+    private GlobalSearchSupport mGlobalSearchSupport;
+
+    private ContentValues mValues = new ContentValues();
+
+    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);
+        mGlobalSearchSupport = new GlobalSearchSupport(this);
+        mLegacyApiSupport = new LegacyApiSupport(context, mOpenHelper, this, mGlobalSearchSupport);
+        mContactAggregator = new ContactAggregator(context, mOpenHelper, mAggregationScheduler);
+
+        final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+        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);
+        mLastTimeContactedUpdate = db.compileStatement("UPDATE " + Tables.RAW_CONTACTS + " SET "
+                + RawContacts.TIMES_CONTACTED + "=" + RawContacts.TIMES_CONTACTED + "+1,"
+                + RawContacts.LAST_TIME_CONTACTED + "=? WHERE " + RawContacts.CONTACT_ID + "=?");
+
+        mContactDisplayNameUpdate = db.compileStatement("UPDATE " + Tables.RAW_CONTACTS + " SET "
+                + RawContactsColumns.DISPLAY_NAME + "=? WHERE " + RawContacts._ID + "=?");
+
+        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));
+
+        mDataRowHandlers = new HashMap<String, DataRowHandler>();
+
+        mDataRowHandlers.put(Email.CONTENT_ITEM_TYPE, new EmailDataRowHandler());
+        mDataRowHandlers.put(Im.CONTENT_ITEM_TYPE,
+                new CommonDataRowHandler(Im.CONTENT_ITEM_TYPE, Im.TYPE, Im.LABEL));
+        mDataRowHandlers.put(Nickname.CONTENT_ITEM_TYPE, new CommonDataRowHandler(
+                StructuredPostal.CONTENT_ITEM_TYPE, StructuredPostal.TYPE, StructuredPostal.LABEL));
+        mDataRowHandlers.put(Organization.CONTENT_ITEM_TYPE, new OrganizationDataRowHandler());
+        mDataRowHandlers.put(Phone.CONTENT_ITEM_TYPE, new PhoneDataRowHandler());
+        mDataRowHandlers.put(Nickname.CONTENT_ITEM_TYPE, new CommonDataRowHandler(
+                Nickname.CONTENT_ITEM_TYPE, Nickname.TYPE, Nickname.LABEL));
+        mDataRowHandlers.put(StructuredName.CONTENT_ITEM_TYPE,
+                new StructuredNameRowHandler(mNameSplitter));
+
+        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();
+    }
+
+    /**
+     * 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;
+    }
+
+    private DataRowHandler getDataRowHandler(final String mimeType) {
+        DataRowHandler handler = mDataRowHandlers.get(mimeType);
+        if (handler == null) {
+            handler = new CustomDataRowHandler(mimeType);
+            mDataRowHandlers.put(mimeType, handler);
+        }
+        return handler;
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        final int match = sUriMatcher.match(uri);
+        long id = 0;
+
+        switch (match) {
+            case SYNCSTATE:
+                id = mOpenHelper.getSyncState().insert(mOpenHelper.getWritableDatabase(), values);
+                break;
+
+            case CONTACTS: {
+                insertContact(values);
+                break;
+            }
+
+            case RAW_CONTACTS: {
+                final Account account = readAccountFromQueryParams(uri);
+                id = insertRawContact(values, account);
+                break;
+            }
+
+            case RAW_CONTACTS_DATA: {
+                values.put(Data.RAW_CONTACT_ID, uri.getPathSegments().get(1));
+                id = insertData(values);
+                break;
+            }
+
+            case DATA: {
+                id = insertData(values);
+                break;
+            }
+
+            case GROUPS: {
+                final Account account = readAccountFromQueryParams(uri);
+                id = insertGroup(values, account);
+                break;
+            }
+
+            case PRESENCE: {
+                id = insertPresence(values);
+                break;
+            }
+
+            default:
+                return mLegacyApiSupport.insert(uri, values);
+        }
+
+        if (id < 0) {
+            return null;
+        }
+
+        final Uri result = ContentUris.withAppendedId(uri, id);
+        onChange(result);
+        return result;
+    }
+
+    /**
+     * If account is non-null then store it in the values. If the account is already
+     * specified in the values then it must be consistent with the account, if it is non-null.
+     * @param values the ContentValues to read from and update
+     * @param account the explicitly provided Account
+     * @return false if the accounts are inconsistent
+     */
+    private boolean resolveAccount(ContentValues values, Account account) {
+        // If either is specified then both must be specified.
+        final String accountName = values.getAsString(RawContacts.ACCOUNT_NAME);
+        final String accountType = values.getAsString(RawContacts.ACCOUNT_TYPE);
+        if (!TextUtils.isEmpty(accountName) || !TextUtils.isEmpty(accountType)) {
+            final Account valuesAccount = new Account(accountName, accountType);
+            if (account != null && !valuesAccount.equals(account)) {
+                return false;
+            }
+            account = valuesAccount;
+        }
+        if (account != null) {
+            values.put(RawContacts.ACCOUNT_NAME, account.mName);
+            values.put(RawContacts.ACCOUNT_TYPE, account.mType);
+        }
+        return true;
+    }
+
+    /**
+     * Inserts an item in the contacts table
+     *
+     * @param values the values for the new row
+     * @return the row ID of the newly created row
+     */
+    private long insertContact(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 insertRawContact(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(RawContacts.CONTACT_ID);
+        if (!resolveAccount(overriddenValues, account)) {
+            return -1;
+        }
+
+        return db.insert(Tables.RAW_CONTACTS, RawContacts.CONTACT_ID, overriddenValues);
+    }
+
+    /**
+     * Inserts an item in the data table
+     *
+     * @param values the values for the new row
+     * @return the row ID of the newly created row
+     */
+    private long insertData(ContentValues values) {
+        int aggregationMode = RawContacts.AGGREGATION_MODE_DISABLED;
+
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        long id = 0;
+        db.beginTransaction();
+        try {
+            mValues.clear();
+            mValues.putAll(values);
+
+            long rawContactId = mValues.getAsLong(Data.RAW_CONTACT_ID);
+
+            // Replace package with internal mapping
+            final String packageName = mValues.getAsString(Data.RES_PACKAGE);
+            if (packageName != null) {
+                mValues.put(DataColumns.PACKAGE_ID, mOpenHelper.getPackageId(packageName));
+            }
+            mValues.remove(Data.RES_PACKAGE);
+
+            // Replace mimetype with internal mapping
+            final String mimeType = mValues.getAsString(Data.MIMETYPE);
+            if (TextUtils.isEmpty(mimeType)) {
+                throw new RuntimeException(Data.MIMETYPE + " is required");
+            }
+
+            mValues.put(DataColumns.MIMETYPE_ID, mOpenHelper.getMimeTypeId(mimeType));
+            mValues.remove(Data.MIMETYPE);
+
+            if (GroupMembership.CONTENT_ITEM_TYPE.equals(mimeType)) {
+                boolean containsGroupSourceId = mValues.containsKey(GroupMembership.GROUP_SOURCE_ID);
+                boolean containsGroupId = mValues.containsKey(GroupMembership.GROUP_ROW_ID);
+                if (containsGroupSourceId && containsGroupId) {
+                    throw new IllegalArgumentException(
+                            "you are not allowed to set both the GroupMembership.GROUP_SOURCE_ID "
+                                    + "and GroupMembership.GROUP_ROW_ID");
+                }
+
+                if (!containsGroupSourceId && !containsGroupId) {
+                    throw new IllegalArgumentException(
+                            "you must set exactly one of GroupMembership.GROUP_SOURCE_ID "
+                                    + "and GroupMembership.GROUP_ROW_ID");
+                }
+
+                if (containsGroupSourceId) {
+                    final String sourceId = mValues.getAsString(GroupMembership.GROUP_SOURCE_ID);
+                    final long groupId = getOrMakeGroup(db, rawContactId, sourceId);
+                    mValues.remove(GroupMembership.GROUP_SOURCE_ID);
+                    mValues.put(GroupMembership.GROUP_ROW_ID, groupId);
+                }
+            }
+
+            id = getDataRowHandler(mimeType).insert(db, rawContactId, mValues);
+
+            aggregationMode = mContactAggregator.markContactForAggregation(rawContactId);
+
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+
+        triggerAggregation(id, aggregationMode);
+        return id;
+    }
+
+    private void triggerAggregation(long rawContactId, int aggregationMode) {
+        switch (aggregationMode) {
+            case RawContacts.AGGREGATION_MODE_DEFAULT:
+                mContactAggregator.schedule();
+                break;
+
+            case RawContacts.AGGREGATION_MODE_IMMEDITATE:
+                mContactAggregator.aggregateContact(rawContactId);
+                break;
+
+            case RawContacts.AGGREGATION_MODE_DISABLED:
+                // Do nothing
+                break;
+        }
+    }
+
+    /**
+     * Returns the group id of the group with sourceId and the same account as rawContactId.
+     * If the group doesn't already exist then it is first created,
+     * @param db SQLiteDatabase to use for this operation
+     * @param rawContactId the contact this group is associated with
+     * @param sourceId the sourceIf of the group to query or create
+     * @return the group id of the existing or created group
+     * @throws IllegalArgumentException if the contact is not associated with an account
+     * @throws IllegalStateException if a group needs to be created but the creation failed
+     */
+    private long getOrMakeGroup(SQLiteDatabase db, long rawContactId, String sourceId) {
+        Account account = null;
+        Cursor c = db.query(ContactsQuery.TABLE, ContactsQuery.PROJECTION, RawContacts._ID + "="
+                + rawContactId, null, null, null, null);
+        try {
+            if (c.moveToNext()) {
+                final String accountName = c.getString(ContactsQuery.ACCOUNT_NAME);
+                final String accountType = c.getString(ContactsQuery.ACCOUNT_TYPE);
+                if (!TextUtils.isEmpty(accountName) && !TextUtils.isEmpty(accountType)) {
+                    account = new Account(accountName, accountType);
+                }
+            }
+        } finally {
+            c.close();
+        }
+        if (account == null) {
+            throw new IllegalArgumentException("if the groupmembership only "
+                    + "has a sourceid the the contact must be associate with "
+                    + "an account");
+        }
+
+        // look up the group that contains this sourceId and has the same account name and type
+        // as the contact refered to by rawContactId
+        c = db.query(Tables.GROUPS, new String[]{RawContacts._ID},
+                Clauses.GROUP_HAS_ACCOUNT_AND_SOURCE_ID,
+                new String[]{sourceId, account.mName, account.mType}, null, null, null);
+        try {
+            if (c.moveToNext()) {
+                return c.getLong(0);
+            } else {
+                ContentValues groupValues = new ContentValues();
+                groupValues.put(Groups.ACCOUNT_NAME, account.mName);
+                groupValues.put(Groups.ACCOUNT_TYPE, account.mType);
+                groupValues.put(Groups.SOURCE_ID, sourceId);
+                long groupId = db.insert(Tables.GROUPS, Groups.ACCOUNT_NAME, groupValues);
+                if (groupId < 0) {
+                    throw new IllegalStateException("unable to create a new group with "
+                            + "this sourceid: " + groupValues);
+                }
+                return groupId;
+            }
+        } finally {
+            c.close();
+        }
+    }
+
+    /**
+     * Delete data row by row so that fixing of primaries etc work correctly.
+     */
+    private int deleteData(String selection, String[] selectionArgs) {
+        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        int count = 0;
+        db.beginTransaction();
+        try {
+
+            // Note that the query will return data according to the access restrictions,
+            // so we don't need to worry about deleting data we don't have permission to read.
+            Cursor c = query(Data.CONTENT_URI, DataIdQuery.COLUMNS, selection, selectionArgs, null);
+            try {
+                while(c.moveToNext()) {
+                    long dataId = c.getLong(DataIdQuery._ID);
+                    count += deleteData(dataId);
+                }
+            } finally {
+                c.close();
+            }
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+
+        return count;
+    }
+
+    public int deleteData(long dataId, String[] allowedMimeTypes) {
+        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        Cursor c = db.query(DataQuery.TABLE, DataQuery.COLUMNS,
+                DataColumns.CONCRETE_ID + "=" + dataId, null, null, null, null);
+        // TODO apply restrictions
+        try {
+            if (!c.moveToFirst()) {
+                return 0;
+            }
+
+            String mimeType = c.getString(DataQuery.MIMETYPE);
+            boolean valid = false;
+            for (int i = 0; i < allowedMimeTypes.length; i++) {
+                if (TextUtils.equals(mimeType, allowedMimeTypes[i])) {
+                    valid = true;
+                    break;
+                }
+            }
+
+            if (!valid) {
+                throw new RuntimeException("Data type mismatch: expected "
+                        + Lists.newArrayList(allowedMimeTypes));
+            }
+
+            return getDataRowHandler(mimeType).delete(db, c);
+        } finally {
+            c.close();
+        }
+    }
+
+    /**
+     * Delete the given {@link Data} row, fixing up any {@link Contacts}
+     * 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(DataContactsQuery.TABLE, DataContactsQuery.PROJECTION,
+                    DataColumns.CONCRETE_ID + "=" + dataId, null, null, null, null);
+            if (cursor.moveToFirst()) {
+                aggId = cursor.getLong(DataContactsQuery.CONTACT_ID);
+                mimeId = cursor.getLong(DataContactsQuery.MIMETYPE_ID);
+                if (mimeId == mimePhone) {
+                    dataRaw = cursor.getString(DataContactsQuery.PHONE_NUMBER);
+                    fixOptimal = (cursor.getLong(DataContactsQuery.OPTIMAL_PHONE_ID) == dataId);
+                    fixFallback = (cursor.getLong(DataContactsQuery.FALLBACK_PHONE_ID) == dataId);
+                } else if (mimeId == mimeEmail) {
+                    dataRaw = cursor.getString(DataContactsQuery.EMAIL_DATA);
+                    fixOptimal = (cursor.getLong(DataContactsQuery.OPTIMAL_EMAIL_ID) == dataId);
+                    fixFallback = (cursor.getLong(DataContactsQuery.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,
+                    RawContacts.IS_RESTRICTED,
+                    scoreClause.toString(),
+            };
+
+            final int COL_DATA_ID = 0;
+            final int COL_IS_RESTRICTED = 1;
+            final int COL_SCORE = 2;
+
+            cursor = db.query(Tables.DATA_JOIN_MIMETYPES_RAW_CONTACTS_CONTACTS, PROJ_PRIMARY,
+                    ContactsColumns.CONCRETE_ID + "=" + aggId + " AND " + DataColumns.MIMETYPE_ID
+                            + "=" + mimeId, null, null, null, SCORE);
+
+            if (fixOptimal) {
+                String colId = null;
+                String colIsRestricted = null;
+                if (mimeId == mimePhone) {
+                    colId = ContactsColumns.OPTIMAL_PRIMARY_PHONE_ID;
+                    colIsRestricted = ContactsColumns.OPTIMAL_PRIMARY_PHONE_IS_RESTRICTED;
+                } else if (mimeId == mimeEmail) {
+                    colId = ContactsColumns.OPTIMAL_PRIMARY_EMAIL_ID;
+                    colIsRestricted = ContactsColumns.OPTIMAL_PRIMARY_EMAIL_IS_RESTRICTED;
+                }
+
+                // Start by replacing with null, since fixOptimal told us that
+                // the previous aggregate values are bad.
+                values.putNull(colId);
+                values.putNull(colIsRestricted);
+
+                // 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 newIsRestricted = cursor.getLong(COL_IS_RESTRICTED);
+
+                    if (newOptimal != 0) {
+                        values.put(colId, newOptimal);
+                    }
+                    if (newIsRestricted != 0) {
+                        values.put(colIsRestricted, newIsRestricted);
+                    }
+                }
+            }
+
+            if (fixFallback) {
+                String colId = null;
+                if (mimeId == mimePhone) {
+                    colId = ContactsColumns.FALLBACK_PRIMARY_PHONE_ID;
+                } else if (mimeId == mimeEmail) {
+                    colId = ContactsColumns.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 contact updates we have
+            if (values.size() > 0) {
+                db.update(Tables.CONTACTS, values, ContactsColumns.CONCRETE_ID + "=" + aggId,
+                        null);
+            }
+        }
+
+        return dataDeleted;
+    }
+
+    /**
+     * 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.RES_PACKAGE);
+        if (packageName != null) {
+            overriddenValues.put(GroupsColumns.PACKAGE_ID, mOpenHelper.getPackageId(packageName));
+        }
+        overriddenValues.remove(Groups.RES_PACKAGE);
+
+        return db.insert(Tables.GROUPS, Groups.TITLE, overriddenValues);
+    }
+
+    /**
+     * Inserts a presence update.
+     */
+    public 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 = Im.PROTOCOL_GOOGLE_TALK == Integer.parseInt(protocol);
+
+        StringBuilder selection = new StringBuilder();
+        String[] selectionArgs;
+        if (matchEmail) {
+            selection.append("(" + Clauses.WHERE_IM_MATCHES + ") OR ("
+                    + Clauses.WHERE_EMAIL_MATCHES + ")");
+            selectionArgs = new String[] { protocol, handle, handle };
+        } else {
+            selection.append(Clauses.WHERE_IM_MATCHES);
+            selectionArgs = new String[] { protocol, handle };
+        }
+
+        if (values.containsKey(Presence.DATA_ID)) {
+            selection.append(" AND " + DataColumns.CONCRETE_ID + "=")
+                    .append(values.getAsLong(Presence.DATA_ID));
+        }
+
+        if (values.containsKey(Presence.RAW_CONTACT_ID)) {
+            selection.append(" AND " + DataColumns.CONCRETE_RAW_CONTACT_ID + "=")
+                    .append(values.getAsLong(Presence.RAW_CONTACT_ID));
+        }
+
+        selection.append(" AND ").append(getContactsRestrictionExceptions());
+
+        long dataId = -1;
+        long rawContactId = -1;
+
+        Cursor cursor = null;
+        try {
+            cursor = db.query(DataContactsQuery.TABLE, DataContactsQuery.PROJECTION,
+                    selection.toString(), selectionArgs, null, null, null);
+            if (cursor.moveToFirst()) {
+                dataId = cursor.getLong(DataContactsQuery.DATA_ID);
+                rawContactId = cursor.getLong(DataContactsQuery.RAW_CONTACT_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.RAW_CONTACT_ID, rawContactId);
+
+        // 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 SYNCSTATE:
+                return mOpenHelper.getSyncState().delete(db, selection, selectionArgs);
+
+            case CONTACTS_ID: {
+                long contactId = ContentUris.parseId(uri);
+
+                // Remove references to the contact first
+                ContentValues values = new ContentValues();
+                values.putNull(RawContacts.CONTACT_ID);
+                db.update(Tables.RAW_CONTACTS, values,
+                        RawContacts.CONTACT_ID + "=" + contactId, null);
+
+                return db.delete(Tables.CONTACTS, BaseColumns._ID + "=" + contactId, null);
+            }
+
+            case RAW_CONTACTS_ID: {
+                return deleteRawContact(uri);
+            }
+
+            case DATA: {
+                return deleteData(selection, selectionArgs);
+            }
+
+            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:
+                return mLegacyApiSupport.delete(uri, selection, selectionArgs);
+        }
+    }
+
+    private int deleteRawContact(Uri uri) {
+        boolean permanentDeletion = false;
+        String permanent = uri.getQueryParameter(RawContacts.DELETE_PERMANENTLY);
+        if (permanent != null && !"false".equals(permanent.toLowerCase())) {
+            permanentDeletion = true;
+        }
+
+        long rawContactId = ContentUris.parseId(uri);
+        return deleteRawContact(rawContactId, permanentDeletion);
+    }
+
+    public int deleteRawContact(long rawContactId, boolean permanently) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        // TODO delete aggregation exceptions
+        mOpenHelper.removeContactIfSingleton(rawContactId);
+        if (permanently) {
+            db.delete(Tables.PRESENCE, Presence.RAW_CONTACT_ID + "=" + rawContactId, null);
+            return db.delete(Tables.RAW_CONTACTS, RawContacts._ID + "=" + rawContactId, null);
+        } else {
+            mValues.clear();
+            mValues.put(RawContacts.DELETED, true);
+            mValues.put(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_DISABLED);
+            mValues.putNull(RawContacts.CONTACT_ID);
+            return updateRawContact(rawContactId, mValues, null, null);
+        }
+    }
+
+    private static Account readAccountFromQueryParams(Uri uri) {
+        final String name = uri.getQueryParameter(RawContacts.ACCOUNT_NAME);
+        final String type = uri.getQueryParameter(RawContacts.ACCOUNT_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) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        int count = 0;
+
+        final int match = sUriMatcher.match(uri);
+        switch(match) {
+            case SYNCSTATE:
+                return mOpenHelper.getSyncState().update(db, values, selection, selectionArgs);
+
+            // TODO(emillar): We will want to disallow editing the contacts table at some point.
+            case CONTACTS: {
+                count = db.update(Tables.CONTACTS, values, selection, selectionArgs);
+                break;
+            }
+
+            case CONTACTS_ID: {
+                count = updateContactData(db, ContentUris.parseId(uri), values);
+                break;
+            }
+
+            case DATA: {
+                count = updateData(values, selection, selectionArgs);
+                break;
+            }
+
+            case DATA_ID: {
+                count = updateData(ContentUris.parseId(uri), values);
+                break;
+            }
+
+            case RAW_CONTACTS: {
+                count = db.update(Tables.RAW_CONTACTS, values, selection, selectionArgs);
+                break;
+            }
+
+            case RAW_CONTACTS_ID: {
+                long rawContactId = ContentUris.parseId(uri);
+                count = updateRawContact(rawContactId, 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 contacts
+                if (values.containsKey(Groups.GROUP_VISIBLE)) {
+                    mOpenHelper.updateAllVisible();
+                }
+
+                break;
+            }
+
+            case AGGREGATION_EXCEPTIONS: {
+                count = updateAggregationException(db, values);
+                break;
+            }
+
+            default:
+                return mLegacyApiSupport.update(uri, values, selection, selectionArgs);
+        }
+
+        if (count > 0) {
+            getContext().getContentResolver().notifyChange(uri, null);
+        }
+        return count;
+    }
+
+    private int updateRawContact(long rawContactId, ContentValues values, String selection,
+            String[] selectionArgs) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        String selectionWithId = (RawContacts._ID + " = " + rawContactId + " ")
+                + (selection == null ? "" : " AND " + selection);
+        return db.update(Tables.RAW_CONTACTS, values, selectionWithId, selectionArgs);
+    }
+
+    private int updateData(ContentValues values, String selection,
+            String[] selectionArgs) {
+        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        int count = 0;
+        db.beginTransaction();
+        try {
+
+            // Note that the query will return data according to the access restrictions,
+            // so we don't need to worry about deleting data we don't have permission to read.
+            Cursor c = query(Data.CONTENT_URI, DataIdQuery.COLUMNS, selection, selectionArgs, null);
+            try {
+                while(c.moveToNext()) {
+                    long dataId = c.getLong(DataIdQuery._ID);
+                    count += updateData(dataId, values);
+                }
+            } finally {
+                c.close();
+            }
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+
+        return count;
+    }
+
+    private int updateData(long dataId, ContentValues values) {
+        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        mValues.clear();
+        mValues.putAll(values);
+        mValues.remove(Data._ID);
+        mValues.remove(Data.RAW_CONTACT_ID);
+        mValues.remove(Data.MIMETYPE);
+
+        String packageName = values.getAsString(Data.RES_PACKAGE);
+        if (packageName != null) {
+            mValues.remove(Data.RES_PACKAGE);
+            mValues.put(DataColumns.PACKAGE_ID, mOpenHelper.getPackageId(packageName));
+        }
+
+        boolean containsIsSuperPrimary = mValues.containsKey(Data.IS_SUPER_PRIMARY);
+        boolean containsIsPrimary = mValues.containsKey(Data.IS_PRIMARY);
+
+        // Remove primary or super primary values being set to 0. This is disallowed by the
+        // content provider.
+        if (containsIsSuperPrimary && mValues.getAsInteger(Data.IS_SUPER_PRIMARY) == 0) {
+            containsIsSuperPrimary = false;
+            mValues.remove(Data.IS_SUPER_PRIMARY);
+        }
+        if (containsIsPrimary && mValues.getAsInteger(Data.IS_PRIMARY) == 0) {
+            containsIsPrimary = false;
+            mValues.remove(Data.IS_PRIMARY);
+        }
+
+        if (containsIsSuperPrimary) {
+            setIsSuperPrimary(dataId);
+            setIsPrimary(dataId);
+
+            // Now that we've taken care of setting these, remove them from "values".
+            mValues.remove(Data.IS_SUPER_PRIMARY);
+            if (containsIsPrimary) {
+                mValues.remove(Data.IS_PRIMARY);
+            }
+        } else if (containsIsPrimary) {
+            setIsPrimary(dataId);
+
+            // Now that we've taken care of setting this, remove it from "values".
+            mValues.remove(Data.IS_PRIMARY);
+        }
+
+        if (mValues.size() > 0) {
+            return db.update(Tables.DATA, mValues, Data._ID + " = " + dataId, null);
+        }
+        return 0;
+    }
+
+    private int updateContactData(SQLiteDatabase db, long contactId, ContentValues values) {
+
+        // First update all constituent contacts
+        ContentValues optionValues = new ContentValues(5);
+        OpenHelper.copyStringValue(optionValues, RawContacts.CUSTOM_RINGTONE,
+                values, Contacts.CUSTOM_RINGTONE);
+        OpenHelper.copyLongValue(optionValues, RawContacts.SEND_TO_VOICEMAIL,
+                values, Contacts.SEND_TO_VOICEMAIL);
+        OpenHelper.copyLongValue(optionValues, RawContacts.LAST_TIME_CONTACTED,
+                values, Contacts.LAST_TIME_CONTACTED);
+        OpenHelper.copyLongValue(optionValues, RawContacts.TIMES_CONTACTED,
+                values, Contacts.TIMES_CONTACTED);
+        OpenHelper.copyLongValue(optionValues, RawContacts.STARRED,
+                values, Contacts.STARRED);
+
+        // Nothing to update - just return
+        if (optionValues.size() == 0) {
+            return 0;
+        }
+
+        db.update(Tables.RAW_CONTACTS, optionValues,
+                RawContacts.CONTACT_ID + "=" + contactId, null);
+        return db.update(Tables.CONTACTS, values, Contacts._ID + "=" + contactId, null);
+    }
+
+    public void updateContactTime(long contactId, long lastTimeContacted) {
+        mLastTimeContactedUpdate.bindLong(1, lastTimeContacted);
+        mLastTimeContactedUpdate.bindLong(2, contactId);
+        mLastTimeContactedUpdate.execute();
+    }
+
+    private static class RawContactPair {
+        final long rawContactId1;
+        final long rawContactId2;
+
+        /**
+         * Constructor that ensures that this.rawContactId1 &lt; this.rawContactId2
+         */
+        public RawContactPair(long rawContactId1, long rawContactId2) {
+            if (rawContactId1 < rawContactId2) {
+                this.rawContactId1 = rawContactId1;
+                this.rawContactId2 = rawContactId2;
+            } else {
+                this.rawContactId2 = rawContactId1;
+                this.rawContactId1 = rawContactId2;
+            }
+        }
+    }
+
+    private int updateAggregationException(SQLiteDatabase db, ContentValues values) {
+        int exceptionType = values.getAsInteger(AggregationExceptions.TYPE);
+        long contactId = values.getAsInteger(AggregationExceptions.CONTACT_ID);
+        long rawContactId = values.getAsInteger(AggregationExceptions.RAW_CONTACT_ID);
+
+        // First, we build a list of contactID-contactID pairs for the given contact and contact.
+        ArrayList<RawContactPair> pairs = new ArrayList<RawContactPair>();
+        Cursor c = db.query(ContactsQuery.TABLE, ContactsQuery.PROJECTION, RawContacts.CONTACT_ID
+                + "=" + contactId, null, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long aggregatedContactId = c.getLong(ContactsQuery.RAW_CONTACT_ID);
+                if (aggregatedContactId != rawContactId) {
+                    pairs.add(new RawContactPair(aggregatedContactId, rawContactId));
+                }
+            }
+        } 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 (RawContactPair pair : pairs) {
+            final String whereClause =
+                    AggregationExceptionColumns.RAW_CONTACT_ID1 + "=" + pair.rawContactId1 + " AND "
+                    + AggregationExceptionColumns.RAW_CONTACT_ID2 + "=" + pair.rawContactId2;
+            if (exceptionType == AggregationExceptions.TYPE_AUTOMATIC) {
+                db.delete(Tables.AGGREGATION_EXCEPTIONS, whereClause, null);
+            } else {
+                exceptionValues.put(AggregationExceptionColumns.RAW_CONTACT_ID1, pair.rawContactId1);
+                exceptionValues.put(AggregationExceptionColumns.RAW_CONTACT_ID2, pair.rawContactId2);
+                db.replace(Tables.AGGREGATION_EXCEPTIONS, AggregationExceptions._ID,
+                        exceptionValues);
+            }
+        }
+
+        int aggregationMode = mContactAggregator.markContactForAggregation(rawContactId);
+        if (aggregationMode != RawContacts.AGGREGATION_MODE_DISABLED) {
+            mContactAggregator.aggregateContact(db, rawContactId);
+            if (exceptionType == AggregationExceptions.TYPE_AUTOMATIC
+                    || exceptionType == AggregationExceptions.TYPE_KEEP_OUT) {
+                mContactAggregator.updateAggregateData(contactId);
+            }
+        }
+
+        // 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 = getLimit(uri);
+
+        String contactIdColName = Tables.CONTACTS + "." + Contacts._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 SYNCSTATE:
+                return mOpenHelper.getSyncState().query(db, projection, selection,  selectionArgs,
+                        sortOrder);
+
+            case CONTACTS: {
+                qb.setTables(Tables.CONTACTS);
+                applyAggregateRestrictionExceptions(qb);
+                applyAggregatePrimaryRestrictionExceptions(sContactsProjectionMap);
+                qb.setProjectionMap(sContactsProjectionMap);
+                break;
+            }
+
+            case CONTACTS_ID: {
+                long aggId = ContentUris.parseId(uri);
+                qb.setTables(Tables.CONTACTS);
+                qb.appendWhere(ContactsColumns.CONCRETE_ID + "=" + aggId + " AND ");
+                applyAggregateRestrictionExceptions(qb);
+                applyAggregatePrimaryRestrictionExceptions(sContactsProjectionMap);
+                qb.setProjectionMap(sContactsProjectionMap);
+                break;
+            }
+
+            case CONTACTS_SUMMARY: {
+                // TODO: join into social status tables
+                qb.setTables(Tables.CONTACTS_JOIN_PRESENCE_PRIMARY_PHONE);
+                applyAggregateRestrictionExceptions(qb);
+                applyAggregatePrimaryRestrictionExceptions(sContactsSummaryProjectionMap);
+                projection = assertContained(projection, Contacts.PRIMARY_PHONE_ID);
+                qb.setProjectionMap(sContactsSummaryProjectionMap);
+                groupBy = contactIdColName;
+                break;
+            }
+
+            case CONTACTS_SUMMARY_ID: {
+                // TODO: join into social status tables
+                long aggId = ContentUris.parseId(uri);
+                qb.setTables(Tables.CONTACTS_JOIN_PRESENCE_PRIMARY_PHONE);
+                qb.appendWhere(ContactsColumns.CONCRETE_ID + "=" + aggId + " AND ");
+                applyAggregateRestrictionExceptions(qb);
+                applyAggregatePrimaryRestrictionExceptions(sContactsSummaryProjectionMap);
+                projection = assertContained(projection, Contacts.PRIMARY_PHONE_ID);
+                qb.setProjectionMap(sContactsSummaryProjectionMap);
+                groupBy = contactIdColName;
+                break;
+            }
+
+            case CONTACTS_SUMMARY_FILTER: {
+                // TODO: filter query based on callingUid
+                qb.setTables(Tables.CONTACTS_JOIN_PRESENCE_PRIMARY_PHONE);
+                qb.setProjectionMap(sContactsSummaryProjectionMap);
+                if (uri.getPathSegments().size() > 2) {
+                    qb.appendWhere(buildContactLookupWhereClause(uri.getLastPathSegment()));
+                }
+                groupBy = contactIdColName;
+                break;
+            }
+
+            case CONTACTS_SUMMARY_STREQUENT_FILTER:
+            case CONTACTS_SUMMARY_STREQUENT: {
+                // Build the first query for starred
+                qb.setTables(Tables.CONTACTS_JOIN_PRESENCE_PRIMARY_PHONE);
+                qb.setProjectionMap(sContactsSummaryProjectionMap);
+                if (match == CONTACTS_SUMMARY_STREQUENT_FILTER
+                        && uri.getPathSegments().size() > 3) {
+                    qb.appendWhere(buildContactLookupWhereClause(uri.getLastPathSegment()));
+                }
+                final String starredQuery = qb.buildQuery(projection, Contacts.STARRED + "=1",
+                        null, contactIdColName, null, null,
+                        null /* limit */);
+
+                // Build the second query for frequent
+                qb = new SQLiteQueryBuilder();
+                qb.setTables(Tables.CONTACTS_JOIN_PRESENCE_PRIMARY_PHONE);
+                qb.setProjectionMap(sContactsSummaryProjectionMap);
+                if (match == CONTACTS_SUMMARY_STREQUENT_FILTER
+                        && uri.getPathSegments().size() > 3) {
+                    qb.appendWhere(buildContactLookupWhereClause(uri.getLastPathSegment()));
+                }
+                final String frequentQuery = qb.buildQuery(projection,
+                        Contacts.TIMES_CONTACTED + " > 0 AND (" + Contacts.STARRED
+                        + " = 0 OR " + Contacts.STARRED + " IS NULL)",
+                        null, contactIdColName, 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.CONTACTS_JOIN_PRESENCE_PRIMARY_PHONE);
+
+                if ((c != null) && !isTemporary()) {
+                    c.setNotificationUri(getContext().getContentResolver(),
+                            ContactsContract.AUTHORITY_URI);
+                }
+                return c;
+            }
+
+            case CONTACTS_SUMMARY_GROUP: {
+                qb.setTables(Tables.CONTACTS_JOIN_PRESENCE_PRIMARY_PHONE);
+                applyAggregateRestrictionExceptions(qb);
+                applyAggregatePrimaryRestrictionExceptions(sContactsSummaryProjectionMap);
+                projection = assertContained(projection, Contacts.PRIMARY_PHONE_ID);
+                qb.setProjectionMap(sContactsSummaryProjectionMap);
+                if (uri.getPathSegments().size() > 2) {
+                    qb.appendWhere(" AND " + sContactsInGroupSelect);
+                    selectionArgs = appendGroupArg(selectionArgs, uri.getLastPathSegment());
+                }
+                groupBy = contactIdColName;
+                break;
+            }
+
+            case CONTACTS_DATA: {
+                long aggId = Long.parseLong(uri.getPathSegments().get(1));
+                qb.setTables(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS_GROUPS);
+                qb.setProjectionMap(sDataRawContactsGroupsContactProjectionMap);
+                qb.appendWhere(RawContacts.CONTACT_ID + "=" + aggId + " AND ");
+                applyDataRestrictionExceptions(qb);
+                break;
+            }
+
+            case CONTACTS_RAW_CONTACTS: {
+                long contactId = Long.parseLong(uri.getPathSegments().get(1));
+                qb.setTables(Tables.RAW_CONTACTS_JOIN_CONTACTS);
+                qb.setProjectionMap(sRawContactsContactsProjectionMap);
+                qb.appendWhere(RawContacts.CONTACT_ID + "=" + contactId + " AND ");
+                applyDataRestrictionExceptions(qb);
+                break;
+            }
+
+            case PHONES_FILTER: {
+                qb.setTables(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
+                qb.setProjectionMap(sDataRawContactsContactProjectionMap);
+                qb.appendWhere(Data.MIMETYPE + " = '" + Phone.CONTENT_ITEM_TYPE + "'");
+                if (uri.getPathSegments().size() > 2) {
+                    qb.appendWhere(" AND " + buildContactLookupWhereClause(
+                            uri.getLastPathSegment()));
+                }
+                break;
+            }
+
+            case PHONES: {
+                qb.setTables(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
+                qb.setProjectionMap(sDataRawContactsContactProjectionMap);
+                qb.appendWhere(Data.MIMETYPE + " = \"" + Phone.CONTENT_ITEM_TYPE + "\"");
+                break;
+            }
+
+            case POSTALS: {
+                qb.setTables(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
+                qb.setProjectionMap(sDataRawContactsContactProjectionMap);
+                qb.appendWhere(Data.MIMETYPE + " = \"" + StructuredPostal.CONTENT_ITEM_TYPE + "\"");
+                break;
+            }
+
+            case RAW_CONTACTS: {
+                qb.setTables(Tables.RAW_CONTACTS);
+                qb.setProjectionMap(sRawContactsProjectionMap);
+                applyContactsRestrictionExceptions(qb);
+                break;
+            }
+
+            case RAW_CONTACTS_ID: {
+                long rawContactId = ContentUris.parseId(uri);
+                qb.setTables(Tables.RAW_CONTACTS);
+                qb.setProjectionMap(sRawContactsProjectionMap);
+                qb.appendWhere(RawContactsColumns.CONCRETE_ID + "=" + rawContactId + " AND ");
+                applyContactsRestrictionExceptions(qb);
+                break;
+            }
+
+            case RAW_CONTACTS_DATA: {
+                long rawContactId = Long.parseLong(uri.getPathSegments().get(1));
+                qb.setTables(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_GROUPS);
+                qb.setProjectionMap(sDataRawContactsGroupsProjectionMap);
+                qb.appendWhere(Data.RAW_CONTACT_ID + "=" + rawContactId + " AND ");
+                applyDataRestrictionExceptions(qb);
+                break;
+            }
+
+            case CONTACTS_FILTER_EMAIL: {
+                // TODO: filter query based on callingUid
+                qb.setTables(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
+                qb.setProjectionMap(sDataRawContactsProjectionMap);
+                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(RawContacts.ACCOUNT_NAME);
+                final String accountType = uri.getQueryParameter(RawContacts.ACCOUNT_TYPE);
+                if (!TextUtils.isEmpty(accountName)) {
+                    qb.appendWhere(RawContactsColumns.CONCRETE_ACCOUNT_NAME + "="
+                            + DatabaseUtils.sqlEscapeString(accountName) + " AND "
+                            + RawContactsColumns.CONCRETE_ACCOUNT_TYPE + "="
+                            + DatabaseUtils.sqlEscapeString(accountType) + " AND ");
+                }
+                qb.setTables(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_GROUPS);
+                qb.setProjectionMap(sDataGroupsProjectionMap);
+                applyDataRestrictionExceptions(qb);
+                break;
+            }
+
+            case DATA_ID: {
+                qb.setTables(Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_GROUPS);
+                qb.setProjectionMap(sDataGroupsProjectionMap);
+                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.RAW_CONTACT_ID;
+                }
+
+                final String number = uri.getLastPathSegment();
+                OpenHelper.buildPhoneLookupQuery(qb, number);
+                qb.setProjectionMap(sDataRawContactsProjectionMap);
+                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_RAW_CONTACTS_CONTACTS);
+                qb.setProjectionMap(sGroupsSummaryProjectionMap);
+                groupBy = GroupsColumns.CONCRETE_ID;
+                break;
+            }
+
+            case AGGREGATION_EXCEPTIONS: {
+                qb.setTables(Tables.AGGREGATION_EXCEPTIONS_JOIN_RAW_CONTACTS);
+                qb.setProjectionMap(sAggregationExceptionsProjectionMap);
+                break;
+            }
+
+            case AGGREGATION_SUGGESTIONS: {
+                long contactId = Long.parseLong(uri.getPathSegments().get(1));
+
+                // TODO drop MAX_SUGGESTIONS in favor of LIMIT
+                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(contactId, projection,
+                        sContactsProjectionMap, maxSuggestions);
+            }
+
+            case PRESENCE: {
+                qb.setTables(Tables.PRESENCE);
+                qb.setProjectionMap(sPresenceProjectionMap);
+                break;
+            }
+
+            case PRESENCE_ID: {
+                qb.setTables(Tables.PRESENCE);
+                qb.setProjectionMap(sPresenceProjectionMap);
+                qb.appendWhere(Presence._ID + "=" + ContentUris.parseId(uri));
+                break;
+            }
+
+            case SEARCH_SUGGESTIONS: {
+                return mGlobalSearchSupport.handleSearchSuggestionsQuery(db, uri, limit);
+            }
+
+            case SEARCH_SHORTCUT: {
+                // TODO
+                break;
+            }
+
+            default:
+                return mLegacyApiSupport.query(uri, projection, selection, selectionArgs,
+                        sortOrder, limit);
+        }
+
+        // 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;
+    }
+
+    /**
+     * Gets the value of the "limit" URI query parameter.
+     *
+     * @return A string containing a non-negative integer, or <code>null</code> if
+     *         the parameter is not set, or is set to an invalid value.
+     */
+    private String getLimit(Uri url) {
+        String limitParam = url.getQueryParameter("limit");
+        if (limitParam == null) {
+            return null;
+        }
+        // make sure that the limit is a non-negative integer
+        try {
+            int l = Integer.parseInt(limitParam);
+            if (l < 0) {
+                Log.w(TAG, "Invalid limit parameter: " + limitParam);
+                return null;
+            }
+            return String.valueOf(l);
+        } catch (NumberFormatException ex) {
+            Log.w(TAG, "Invalid limit parameter: " + limitParam);
+            return null;
+        }
+    }
+
+    /**
+     * List of package names with access to {@link RawContacts#IS_RESTRICTED} data.
+     */
+    private static final String[] sAllowedPackages = new String[] {
+        "com.android.contacts",
+        "com.facebook",
+    };
+
+    /**
+     * Check if {@link Binder#getCallingUid()} should be allowed access to
+     * {@link RawContacts#IS_RESTRICTED} data.
+     */
+    private boolean hasRestrictedAccess() {
+        final PackageManager pm = getContext().getPackageManager();
+        final String[] callerPackages = pm.getPackagesForUid(Binder.getCallingUid());
+
+        // Has restricted access if caller matches any packages
+        for (String callerPackage : callerPackages) {
+            for (String allowedPackage : sAllowedPackages) {
+                if (allowedPackage.equals(callerPackage)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Restrict selection of {@link Contacts} to only public ones, or those
+     * the caller has been granted an exception to.
+     */
+    private void applyAggregateRestrictionExceptions(SQLiteQueryBuilder qb) {
+        if (hasRestrictedAccess()) {
+            qb.appendWhere("1");
+        } else {
+            qb.appendWhere(ContactsColumns.SINGLE_IS_RESTRICTED + "=0");
+        }
+    }
+
+    /**
+     * Find any exceptions that have been granted to the calling process, and
+     * add projections to correctly select {@link Contacts#PRIMARY_PHONE_ID}
+     * and {@link Contacts#PRIMARY_EMAIL_ID}.
+     */
+    private void applyAggregatePrimaryRestrictionExceptions(HashMap<String, String> projection) {
+        String projectionPhone;
+        String projectionEmail;
+
+        if (hasRestrictedAccess()) {
+            // With restricted access, always give optimal values
+            projectionPhone = ContactsColumns.OPTIMAL_PRIMARY_PHONE_ID + " AS "
+                    + Contacts.PRIMARY_PHONE_ID;
+            projectionEmail = ContactsColumns.OPTIMAL_PRIMARY_EMAIL_ID + " AS "
+                    + Contacts.PRIMARY_EMAIL_ID;
+        } else {
+            // With general access, always give fallback values
+            projectionPhone = ContactsColumns.FALLBACK_PRIMARY_PHONE_ID + " AS "
+                    + Contacts.PRIMARY_PHONE_ID;
+            projectionEmail = ContactsColumns.FALLBACK_PRIMARY_EMAIL_ID + " AS "
+                    + Contacts.PRIMARY_EMAIL_ID;
+        }
+
+        projection.remove(Contacts.PRIMARY_PHONE_ID);
+        projection.put(Contacts.PRIMARY_PHONE_ID, projectionPhone);
+
+        projection.remove(Contacts.PRIMARY_EMAIL_ID);
+        projection.put(Contacts.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) {
+        qb.appendWhere(getContactsRestrictionExceptions());
+    }
+
+    String getContactsRestrictionExceptions() {
+        if (hasRestrictedAccess()) {
+            return "1";
+        } else {
+            return RawContacts.IS_RESTRICTED + "=0";
+        }
+    }
+
+    public String getContactsRestrictionExceptionAsNestedQuery(String contactIdColumn) {
+        if (hasRestrictedAccess()) {
+            return "1";
+        } else {
+            return "(SELECT " + RawContacts.IS_RESTRICTED + " FROM " + Tables.RAW_CONTACTS
+                    + " WHERE " + RawContactsColumns.CONCRETE_ID + "=" + contactIdColumn + ")=0";
+        }
+    }
+
+    /**
+     * 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.
+     */
+    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 static final String[] DATA_KEYS = new String[]{
+                "data1",
+                "data2",
+                "data3",
+                "data4",
+                "data5",
+                "data6",
+                "data7",
+                "data8",
+                "data9",
+                "data10",
+                "data11",
+                "data12",
+                "data13",
+                "data14",
+                "data15"};
+
+        private static final String[] PROJECTION = new String[]{
+                RawContacts.ACCOUNT_NAME,
+                RawContacts.ACCOUNT_TYPE,
+                RawContacts.SOURCE_ID,
+                RawContacts.VERSION,
+                RawContacts.DIRTY,
+                RawContacts.Data._ID,
+                RawContacts.Data.RES_PACKAGE,
+                RawContacts.Data.MIMETYPE,
+                RawContacts.Data.DATA1,
+                RawContacts.Data.DATA2,
+                RawContacts.Data.DATA3,
+                RawContacts.Data.DATA4,
+                RawContacts.Data.DATA5,
+                RawContacts.Data.DATA6,
+                RawContacts.Data.DATA7,
+                RawContacts.Data.DATA8,
+                RawContacts.Data.DATA9,
+                RawContacts.Data.DATA10,
+                RawContacts.Data.DATA11,
+                RawContacts.Data.DATA12,
+                RawContacts.Data.DATA13,
+                RawContacts.Data.DATA14,
+                RawContacts.Data.DATA15,
+                RawContacts.Data.RAW_CONTACT_ID,
+                RawContacts.Data.IS_PRIMARY,
+                RawContacts.Data.DATA_VERSION,
+                GroupMembership.GROUP_SOURCE_ID};
+
+        private static final int COLUMN_ACCOUNT_NAME = 0;
+        private static final int COLUMN_ACCOUNT_TYPE = 1;
+        private static final int COLUMN_SOURCE_ID = 2;
+        private static final int COLUMN_VERSION = 3;
+        private static final int COLUMN_DIRTY = 4;
+        private static final int COLUMN_DATA_ID = 5;
+        private static final int COLUMN_RES_PACKAGE = 6;
+        private static final int COLUMN_MIMETYPE = 7;
+        private static final int COLUMN_DATA1 = 8;
+        private static final int COLUMN_CONTACT_ID = 23;
+        private static final int COLUMN_IS_PRIMARY = 24;
+        private static final int COLUMN_DATA_VERSION = 25;
+        private static final int COLUMN_GROUP_SOURCE_ID = 26;
+
+        public ContactsEntityIterator(ContactsProvider2 provider, String contactsIdString, Uri uri,
+                String selection, String[] selectionArgs, String sortOrder) {
+            mIsClosed = false;
+
+            final String updatedSortOrder = (sortOrder == null)
+                    ? RawContacts.Data.RAW_CONTACT_ID
+                    : (RawContacts.Data.RAW_CONTACT_ID + "," + sortOrder);
+
+            final SQLiteDatabase db = provider.mOpenHelper.getReadableDatabase();
+            final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+            qb.setTables(Tables.CONTACT_ENTITIES);
+            if (contactsIdString != null) {
+                qb.appendWhere(Data.RAW_CONTACT_ID + "=" + contactsIdString);
+            }
+            final String accountName = uri.getQueryParameter(RawContacts.ACCOUNT_NAME);
+            final String accountType = uri.getQueryParameter(RawContacts.ACCOUNT_TYPE);
+            if (!TextUtils.isEmpty(accountName)) {
+                qb.appendWhere(RawContacts.ACCOUNT_NAME + "="
+                        + DatabaseUtils.sqlEscapeString(accountName) + " AND "
+                        + RawContacts.ACCOUNT_TYPE + "="
+                        + DatabaseUtils.sqlEscapeString(accountType));
+            }
+            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 rawContactId = 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(RawContacts.ACCOUNT_NAME, c.getString(COLUMN_ACCOUNT_NAME));
+            contactValues.put(RawContacts.ACCOUNT_TYPE, c.getString(COLUMN_ACCOUNT_TYPE));
+            contactValues.put(RawContacts._ID, rawContactId);
+            contactValues.put(RawContacts.DIRTY, c.getLong(COLUMN_DIRTY));
+            contactValues.put(RawContacts.VERSION, c.getLong(COLUMN_VERSION));
+            contactValues.put(RawContacts.SOURCE_ID, c.getString(COLUMN_SOURCE_ID));
+            Entity contact = new Entity(contactValues);
+
+            // read data rows until the contact id changes
+            do {
+                if (rawContactId != c.getLong(COLUMN_CONTACT_ID)) {
+                    break;
+                }
+                // add the data to to the contact
+                ContentValues dataValues = new ContentValues();
+                dataValues.put(RawContacts.Data._ID, c.getString(COLUMN_DATA_ID));
+                dataValues.put(RawContacts.Data.RES_PACKAGE, c.getString(COLUMN_RES_PACKAGE));
+                dataValues.put(RawContacts.Data.MIMETYPE, c.getString(COLUMN_MIMETYPE));
+                dataValues.put(RawContacts.Data.IS_PRIMARY, c.getString(COLUMN_IS_PRIMARY));
+                dataValues.put(RawContacts.Data.DATA_VERSION, c.getLong(COLUMN_DATA_VERSION));
+                if (!c.isNull(COLUMN_GROUP_SOURCE_ID)) {
+                    dataValues.put(GroupMembership.GROUP_SOURCE_ID,
+                            c.getString(COLUMN_GROUP_SOURCE_ID));
+                }
+                dataValues.put(RawContacts.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;
+        }
+    }
+
+    /**
+     * 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 GroupsEntityIterator implements EntityIterator {
+        private final Cursor mEntityCursor;
+        private volatile boolean mIsClosed;
+
+        private static final String[] PROJECTION = new String[]{
+                Groups._ID,
+                Groups.ACCOUNT_NAME,
+                Groups.ACCOUNT_TYPE,
+                Groups.SOURCE_ID,
+                Groups.DIRTY,
+                Groups.VERSION,
+                Groups.RES_PACKAGE,
+                Groups.TITLE,
+                Groups.TITLE_RES,
+                Groups.GROUP_VISIBLE};
+
+        private static final int COLUMN_ID = 0;
+        private static final int COLUMN_ACCOUNT_NAME = 1;
+        private static final int COLUMN_ACCOUNT_TYPE = 2;
+        private static final int COLUMN_SOURCE_ID = 3;
+        private static final int COLUMN_DIRTY = 4;
+        private static final int COLUMN_VERSION = 5;
+        private static final int COLUMN_RES_PACKAGE = 6;
+        private static final int COLUMN_TITLE = 7;
+        private static final int COLUMN_TITLE_RES = 8;
+        private static final int COLUMN_GROUP_VISIBLE = 9;
+
+        public GroupsEntityIterator(ContactsProvider2 provider, String groupIdString, Uri uri,
+                String selection, String[] selectionArgs, String sortOrder) {
+            mIsClosed = false;
+
+            final String updatedSortOrder = (sortOrder == null)
+                    ? Groups._ID
+                    : (Groups._ID + "," + sortOrder);
+
+            final SQLiteDatabase db = provider.mOpenHelper.getReadableDatabase();
+            final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+            qb.setTables(Tables.GROUPS_JOIN_PACKAGES);
+            qb.setProjectionMap(sGroupsProjectionMap);
+            if (groupIdString != null) {
+                qb.appendWhere(Groups._ID + "=" + groupIdString);
+            }
+            final String accountName = uri.getQueryParameter(Groups.ACCOUNT_NAME);
+            final String accountType = uri.getQueryParameter(Groups.ACCOUNT_TYPE);
+            if (!TextUtils.isEmpty(accountName)) {
+                qb.appendWhere(Groups.ACCOUNT_NAME + "="
+                        + DatabaseUtils.sqlEscapeString(accountName) + " AND "
+                        + Groups.ACCOUNT_TYPE + "="
+                        + DatabaseUtils.sqlEscapeString(accountType));
+            }
+            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 groupId = c.getLong(COLUMN_ID);
+
+            // we expect the cursor is already at the row we need to read from
+            ContentValues groupValues = new ContentValues();
+            groupValues.put(Groups.ACCOUNT_NAME, c.getString(COLUMN_ACCOUNT_NAME));
+            groupValues.put(Groups.ACCOUNT_TYPE, c.getString(COLUMN_ACCOUNT_TYPE));
+            groupValues.put(Groups._ID, groupId);
+            groupValues.put(Groups.DIRTY, c.getLong(COLUMN_DIRTY));
+            groupValues.put(Groups.VERSION, c.getLong(COLUMN_VERSION));
+            groupValues.put(Groups.SOURCE_ID, c.getString(COLUMN_SOURCE_ID));
+            groupValues.put(Groups.RES_PACKAGE, c.getString(COLUMN_RES_PACKAGE));
+            groupValues.put(Groups.TITLE, c.getString(COLUMN_TITLE));
+            groupValues.put(Groups.TITLE_RES, c.getString(COLUMN_TITLE_RES));
+            groupValues.put(Groups.GROUP_VISIBLE, c.getLong(COLUMN_GROUP_VISIBLE));
+            Entity group = new Entity(groupValues);
+
+            mEntityCursor.moveToNext();
+
+            return group;
+        }
+    }
+
+    @Override
+    public EntityIterator queryEntities(Uri uri, String selection, String[] selectionArgs,
+            String sortOrder) {
+        final int match = sUriMatcher.match(uri);
+        switch (match) {
+            case RAW_CONTACTS:
+            case RAW_CONTACTS_ID:
+                String contactsIdString = null;
+                if (match == RAW_CONTACTS_ID) {
+                    contactsIdString = uri.getPathSegments().get(1);
+                }
+
+                return new ContactsEntityIterator(this, contactsIdString,
+                        uri, selection, selectionArgs, sortOrder);
+            case GROUPS:
+            case GROUPS_ID:
+                String idString = null;
+                if (match == GROUPS_ID) {
+                    idString = uri.getPathSegments().get(1);
+                }
+
+                return new GroupsEntityIterator(this, idString,
+                        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 CONTACTS: return Contacts.CONTENT_TYPE;
+            case CONTACTS_ID: return Contacts.CONTENT_ITEM_TYPE;
+            case RAW_CONTACTS: return RawContacts.CONTENT_TYPE;
+            case RAW_CONTACTS_ID: return RawContacts.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 Contacts.CONTENT_TYPE;
+            case SEARCH_SUGGESTIONS:
+                return SearchManager.SUGGEST_MIME_TYPE;
+            case SEARCH_SHORTCUT:
+                return SearchManager.SHORTCUT_MIME_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();
+        }
+    }
+
+    private void setDisplayName(long rawContactId, String displayName) {
+        if (displayName != null) {
+            mContactDisplayNameUpdate.bindString(1, displayName);
+        } else {
+            mContactDisplayNameUpdate.bindNull(1);
+        }
+        mContactDisplayNameUpdate.bindLong(2, rawContactId);
+        mContactDisplayNameUpdate.execute();
+    }
+
+    /*
+     * 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;
+        boolean isRestricted = false;
+        String mimeType = null;
+
+        Cursor cursor = null;
+        try {
+            cursor = db.query(DataRawContactsQuery.TABLE, DataRawContactsQuery.PROJECTION,
+                    DataColumns.CONCRETE_ID + "=" + dataId, null, null, null, null);
+            if (cursor.moveToFirst()) {
+                aggId = cursor.getLong(DataRawContactsQuery.CONTACT_ID);
+                isRestricted = (cursor.getInt(DataRawContactsQuery.IS_RESTRICTED) == 1);
+                mimeType = cursor.getString(DataRawContactsQuery.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(ContactsColumns.OPTIMAL_PRIMARY_PHONE_ID, dataId);
+            values.put(ContactsColumns.OPTIMAL_PRIMARY_PHONE_IS_RESTRICTED, isRestricted);
+        } else if (isEmail) {
+            values.put(ContactsColumns.OPTIMAL_PRIMARY_EMAIL_ID, dataId);
+            values.put(ContactsColumns.OPTIMAL_PRIMARY_EMAIL_IS_RESTRICTED, isRestricted);
+        }
+
+        // If this data is unrestricted, then also set as fallback
+        if (!isRestricted && isPhone) {
+            values.put(ContactsColumns.FALLBACK_PRIMARY_PHONE_ID, dataId);
+        } else if (!isRestricted && isEmail) {
+            values.put(ContactsColumns.FALLBACK_PRIMARY_EMAIL_ID, dataId);
+        }
+
+        // Push update into contacts table, if needed
+        if (values.size() > 0) {
+            db.update(Tables.CONTACTS, values, Contacts._ID + "=" + aggId, null);
+        }
+    }
+
+    private String buildContactLookupWhereClause(String filterParam) {
+        StringBuilder filter = new StringBuilder();
+        filter.append(Tables.CONTACTS);
+        filter.append(".");
+        filter.append(Contacts._ID);
+        filter.append(" IN (SELECT ");
+        filter.append(RawContacts.CONTACT_ID);
+        filter.append(" FROM ");
+        filter.append(Tables.RAW_CONTACTS);
+        filter.append(" WHERE ");
+        filter.append(RawContacts._ID);
+        filter.append(" IN ");
+        appendRawContactsByFilterAsNestedQuery(filter, filterParam, null);
+        filter.append(")");
+        return filter.toString();
+    }
+
+    public String getRawContactsByFilterAsNestedQuery(String filterParam) {
+        StringBuilder sb = new StringBuilder();
+        appendRawContactsByFilterAsNestedQuery(sb, filterParam, null);
+        return sb.toString();
+    }
+
+    public void appendRawContactsByFilterAsNestedQuery(StringBuilder sb, String filterParam,
+            String limit) {
+        sb.append("(SELECT DISTINCT raw_contact_id FROM name_lookup WHERE normalized_name GLOB '");
+        sb.append(NameNormalizer.normalize(filterParam));
+        sb.append("*'");
+        if (limit != null) {
+            sb.append(" LIMIT ").append(limit);
+        }
+        sb.append(")");
+    }
+
+    private String[] appendGroupArg(String[] selectionArgs, String arg) {
+        if (selectionArgs == null) {
+            return new String[] {arg};
+        } else {
+            int newLength = selectionArgs.length + 1;
+            String[] newSelectionArgs = new String[newLength];
+            System.arraycopy(selectionArgs, 0, newSelectionArgs, 0, selectionArgs.length);
+            newSelectionArgs[newLength - 1] = arg;
+            return newSelectionArgs;
+        }
+    }
+}
diff --git a/src/com/android/providers/contacts/GlobalSearchSupport.java b/src/com/android/providers/contacts/GlobalSearchSupport.java
new file mode 100644
index 0000000..40e1089
--- /dev/null
+++ b/src/com/android/providers/contacts/GlobalSearchSupport.java
@@ -0,0 +1,327 @@
+/*
+ * 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.database.ArrayListCursor;
+import com.android.providers.contacts.OpenHelper.ContactsColumns;
+import com.android.providers.contacts.OpenHelper.DataColumns;
+import com.android.providers.contacts.OpenHelper.MimetypesColumns;
+import com.android.providers.contacts.OpenHelper.RawContactsColumns;
+import com.android.providers.contacts.OpenHelper.Tables;
+
+import android.app.SearchManager;
+import android.content.ContentUris;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.provider.Contacts.Intents;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Presence;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.text.TextUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+
+/**
+ * Support for global search integration for Contacts.
+ */
+public class GlobalSearchSupport {
+
+    private static final String[] SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS = {
+            "_id",
+            SearchManager.SUGGEST_COLUMN_TEXT_1,
+            SearchManager.SUGGEST_COLUMN_TEXT_2,
+            SearchManager.SUGGEST_COLUMN_ICON_1,
+            SearchManager.SUGGEST_COLUMN_INTENT_DATA,
+            SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
+            SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
+    };
+
+    private static final String[] SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS = {
+            "_id",
+            SearchManager.SUGGEST_COLUMN_TEXT_1,
+            SearchManager.SUGGEST_COLUMN_TEXT_2,
+            SearchManager.SUGGEST_COLUMN_ICON_1,
+            SearchManager.SUGGEST_COLUMN_ICON_2,
+            SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID,
+            SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
+    };
+
+    private interface SearchSuggestionQuery {
+        public static final String JOIN_RAW_CONTACTS =
+                " JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id) ";
+
+        public static final String JOIN_CONTACTS =
+                " JOIN contacts ON (raw_contacts.contact_id = contacts._id)";
+
+        public static final String JOIN_MIMETYPES =
+                " JOIN mimetypes ON (data.mimetype_id = mimetypes._id AND mimetypes.mimetype IN ('"
+                + StructuredName.CONTENT_ITEM_TYPE + "','" + Email.CONTENT_ITEM_TYPE + "','"
+                + Phone.CONTENT_ITEM_TYPE + "','" + Organization.CONTENT_ITEM_TYPE + "','"
+                + Photo.CONTENT_ITEM_TYPE + "','" + GroupMembership.CONTENT_ITEM_TYPE + "')) ";
+
+        public static final String TABLE = "data " + JOIN_RAW_CONTACTS + JOIN_MIMETYPES
+                + JOIN_CONTACTS;
+
+        public static final String PRESENCE_SQL = "(SELECT MAX(" + Presence.PRESENCE_STATUS
+                + ") FROM " + Tables.PRESENCE + " WHERE " + Tables.PRESENCE + "."
+                + Presence.RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID + ")";
+
+        public static final String[] COLUMNS = {
+            ContactsColumns.CONCRETE_ID + " AS " + Contacts._ID,
+            ContactsColumns.CONCRETE_DISPLAY_NAME + " AS " + Contacts.DISPLAY_NAME,
+            PRESENCE_SQL + " AS " + Contacts.PRESENCE_STATUS,
+            DataColumns.CONCRETE_ID + " AS data_id",
+            MimetypesColumns.MIMETYPE,
+            Data.IS_SUPER_PRIMARY,
+            Data.DATA2,
+        };
+
+        public static final int CONTACT_ID = 0;
+        public static final int DISPLAY_NAME = 1;
+        public static final int PRESENCE_STATUS = 2;
+        public static final int DATA_ID = 3;
+        public static final int MIMETYPE = 4;
+        public static final int IS_SUPER_PRIMARY = 5;
+        public static final int DATA2 = 6;
+    }
+
+    private static class SearchSuggestion {
+        String contactId;
+        boolean titleIsName;
+        String organization;
+        String email;
+        String phoneNumber;
+        String photoUri;
+        String normalizedName;
+        int presence = -1;
+        boolean processed;
+        String text1;
+        String text2;
+        String icon1;
+        String icon2;
+
+        public SearchSuggestion(long contactId) {
+            this.contactId = String.valueOf(contactId);
+        }
+
+        private void process() {
+            if (processed) {
+                return;
+            }
+
+            boolean hasOrganization = !TextUtils.isEmpty(organization);
+            boolean hasEmail = !TextUtils.isEmpty(email);
+            boolean hasPhone = !TextUtils.isEmpty(phoneNumber);
+
+            boolean titleIsOrganization = !titleIsName && hasOrganization;
+            boolean titleIsEmail = !titleIsName && !titleIsOrganization && hasEmail;
+            boolean titleIsPhone = !titleIsName && !titleIsOrganization && !titleIsEmail
+                    && hasPhone;
+
+            if (!titleIsOrganization && hasOrganization) {
+                text2 = organization;
+            } else if (!titleIsEmail && hasEmail) {
+                text2 = email;
+            } else if (!titleIsPhone && hasPhone) {
+                text2 = phoneNumber;
+            }
+
+            if (photoUri != null) {
+                icon1 = photoUri;
+            } else {
+                icon1 = String.valueOf(com.android.internal.R.drawable.ic_contact_picture);
+            }
+
+            if (presence != -1) {
+                icon2 = String.valueOf(Presence.getPresenceIconResourceId(presence));
+            }
+
+            processed = true;
+        }
+
+        public String getSortKey() {
+            if (normalizedName == null) {
+                process();
+                normalizedName = text1 == null ? "" : NameNormalizer.normalize(text1);
+            }
+            return normalizedName;
+        }
+
+        @SuppressWarnings({"unchecked"})
+        public ArrayList asList() {
+            process();
+
+            ArrayList<Object> list = new ArrayList<Object>();
+            list.add(contactId);
+            list.add(text1);
+            list.add(text2);
+            list.add(icon1);
+            list.add(icon2);
+            list.add(contactId);
+            list.add(contactId);
+            return list;
+        }
+    }
+
+    private final ContactsProvider2 mContactsProvider;
+
+    public GlobalSearchSupport(ContactsProvider2 contactsProvider) {
+        mContactsProvider = contactsProvider;
+    }
+
+    public Cursor handleSearchSuggestionsQuery(SQLiteDatabase db, Uri url, String limit) {
+        if (url.getPathSegments().size() <= 1) {
+            return null;
+        }
+
+        final String searchClause = url.getLastPathSegment();
+        if (TextUtils.isDigitsOnly(searchClause)) {
+            return buildCursorForSearchSuggestionsBasedOnPhoneNumber(searchClause);
+        } else {
+            return buildCursorForSearchSuggestionsBasedOnName(db, searchClause, limit);
+        }
+    }
+
+    private Cursor buildCursorForSearchSuggestionsBasedOnPhoneNumber(String searchClause) {
+        Resources r = mContactsProvider.getContext().getResources();
+        String s;
+        int i;
+
+        ArrayList<Object> dialNumber = new ArrayList<Object>();
+        dialNumber.add(0);  // _id
+        s = r.getString(com.android.internal.R.string.dial_number_using, searchClause);
+        i = s.indexOf('\n');
+        if (i < 0) {
+            dialNumber.add(s);
+            dialNumber.add("");
+        } else {
+            dialNumber.add(s.substring(0, i));
+            dialNumber.add(s.substring(i + 1));
+        }
+        dialNumber.add(String.valueOf(com.android.internal.R.drawable.call_contact));
+        dialNumber.add("tel:" + searchClause);
+        dialNumber.add(Intents.SEARCH_SUGGESTION_DIAL_NUMBER_CLICKED);
+        dialNumber.add(null);
+
+        ArrayList<Object> createContact = new ArrayList<Object>();
+        createContact.add(1);  // _id
+        s = r.getString(com.android.internal.R.string.create_contact_using, searchClause);
+        i = s.indexOf('\n');
+        if (i < 0) {
+            createContact.add(s);
+            createContact.add("");
+        } else {
+            createContact.add(s.substring(0, i));
+            createContact.add(s.substring(i + 1));
+        }
+        createContact.add(String.valueOf(com.android.internal.R.drawable.create_contact));
+        createContact.add("tel:" + searchClause);
+        createContact.add(Intents.SEARCH_SUGGESTION_CREATE_CONTACT_CLICKED);
+        createContact.add(SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT);
+
+        @SuppressWarnings({"unchecked"}) ArrayList<ArrayList> rows = new ArrayList<ArrayList>();
+        rows.add(dialNumber);
+        rows.add(createContact);
+
+        return new ArrayListCursor(SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS, rows);
+    }
+
+    private Cursor buildCursorForSearchSuggestionsBasedOnName(SQLiteDatabase db,
+            String searchClause, String limit) {
+        ArrayList<SearchSuggestion> suggestionList = new ArrayList<SearchSuggestion>();
+        HashMap<Long, SearchSuggestion> suggestionMap = new HashMap<Long, SearchSuggestion>();
+
+        StringBuilder selection = new StringBuilder();
+        selection.append(mContactsProvider.getContactsRestrictionExceptions());
+        selection.append(" AND " + DataColumns.CONCRETE_RAW_CONTACT_ID + " IN ");
+        mContactsProvider.appendRawContactsByFilterAsNestedQuery(selection, searchClause, limit);
+        selection.append(" AND " + Contacts.IN_VISIBLE_GROUP + "=1");
+
+        Cursor c = db.query(true, SearchSuggestionQuery.TABLE,
+                SearchSuggestionQuery.COLUMNS, selection.toString(), null, null, null, null, null);
+        try {
+            while (c.moveToNext()) {
+
+                long contactId = c.getLong(SearchSuggestionQuery.CONTACT_ID);
+                SearchSuggestion suggestion = suggestionMap.get(contactId);
+                if (suggestion == null) {
+                    suggestion = new SearchSuggestion(contactId);
+                    suggestionList.add(suggestion);
+                    suggestionMap.put(contactId, suggestion);
+                }
+
+                boolean isSuperPrimary = c.getInt(SearchSuggestionQuery.IS_SUPER_PRIMARY) != 0;
+                suggestion.text1 = c.getString(SearchSuggestionQuery.DISPLAY_NAME);
+
+                if (!c.isNull(SearchSuggestionQuery.PRESENCE_STATUS)) {
+                    suggestion.presence = c.getInt(SearchSuggestionQuery.PRESENCE_STATUS);
+                }
+
+                String mimetype = c.getString(SearchSuggestionQuery.MIMETYPE);
+                if (StructuredName.CONTENT_ITEM_TYPE.equals(mimetype)) {
+                    suggestion.titleIsName = true;
+                } else if (Photo.CONTENT_ITEM_TYPE.equals(mimetype)) {
+                    if (isSuperPrimary || suggestion.photoUri == null) {
+
+                        // TODO introduce a dedicated URI for contact photo: /contact/#/photo
+                        long dataId = c.getLong(SearchSuggestionQuery.DATA_ID);
+                        suggestion.photoUri =
+                                ContentUris.withAppendedId(Data.CONTENT_URI, dataId).toString();
+                    }
+                } else if (Organization.CONTENT_ITEM_TYPE.equals(mimetype)) {
+                    if (isSuperPrimary || suggestion.organization == null) {
+                        suggestion.organization = c.getString(SearchSuggestionQuery.DATA2);
+                    }
+                } else if (Email.CONTENT_ITEM_TYPE.equals(mimetype)) {
+                    if (isSuperPrimary || suggestion.email == null) {
+                        suggestion.email = c.getString(SearchSuggestionQuery.DATA2);
+                    }
+                } else if (Phone.CONTENT_ITEM_TYPE.equals(mimetype)) {
+                    if (isSuperPrimary || suggestion.phoneNumber == null) {
+                        suggestion.phoneNumber = c.getString(SearchSuggestionQuery.DATA2);
+                    }
+                }
+            }
+        } finally {
+            c.close();
+        }
+
+        Collections.sort(suggestionList, new Comparator<SearchSuggestion>() {
+            public int compare(SearchSuggestion row1, SearchSuggestion row2) {
+                return row1.getSortKey().compareTo(row2.getSortKey());
+            }
+        });
+
+        @SuppressWarnings({"unchecked"}) ArrayList<ArrayList> rows = new ArrayList<ArrayList>();
+        for (int i = 0; i < suggestionList.size(); i++) {
+            rows.add(suggestionList.get(i).asList());
+        }
+
+        return new ArrayListCursor(SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS, rows);
+    }
+}
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/LegacyApiSupport.java b/src/com/android/providers/contacts/LegacyApiSupport.java
new file mode 100644
index 0000000..af4a74e
--- /dev/null
+++ b/src/com/android/providers/contacts/LegacyApiSupport.java
@@ -0,0 +1,1336 @@
+/*
+ * 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.DataColumns;
+import com.android.providers.contacts.OpenHelper.ExtensionsColumns;
+import com.android.providers.contacts.OpenHelper.GroupsColumns;
+import com.android.providers.contacts.OpenHelper.MimetypesColumns;
+import com.android.providers.contacts.OpenHelper.PhoneColumns;
+import com.android.providers.contacts.OpenHelper.RawContactsColumns;
+import com.android.providers.contacts.OpenHelper.Tables;
+
+import android.app.SearchManager;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+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.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.Contacts.ContactMethods;
+import android.provider.Contacts.People;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Groups;
+import android.provider.ContactsContract.Presence;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Note;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.util.Log;
+
+import java.util.HashMap;
+
+public class LegacyApiSupport implements OpenHelper.Delegate {
+
+    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+
+    private static final int PEOPLE = 1;
+    private static final int PEOPLE_ID = 2;
+    private static final int PEOPLE_UPDATE_CONTACT_TIME = 3;
+    private static final int ORGANIZATIONS = 4;
+    private static final int ORGANIZATIONS_ID = 5;
+    private static final int PEOPLE_CONTACTMETHODS = 6;
+    private static final int PEOPLE_CONTACTMETHODS_ID = 7;
+    private static final int CONTACTMETHODS = 8;
+    private static final int CONTACTMETHODS_ID = 9;
+    private static final int PEOPLE_PHONES = 10;
+    private static final int PEOPLE_PHONES_ID = 11;
+    private static final int PHONES = 12;
+    private static final int PHONES_ID = 13;
+    private static final int EXTENSIONS = 14;
+    private static final int EXTENSIONS_ID = 15;
+    private static final int PEOPLE_EXTENSIONS = 16;
+    private static final int PEOPLE_EXTENSIONS_ID = 17;
+    private static final int GROUPS = 18;
+    private static final int GROUPS_ID = 19;
+    private static final int GROUPMEMBERSHIP = 20;
+    private static final int GROUPMEMBERSHIP_ID = 21;
+    private static final int PEOPLE_GROUPMEMBERSHIP = 22;
+    private static final int PEOPLE_GROUPMEMBERSHIP_ID = 23;
+    private static final int PEOPLE_PHOTO = 24;
+    private static final int PHOTOS = 25;
+    private static final int PHOTOS_ID = 26;
+    private static final int PRESENCE = 27;
+    private static final int PRESENCE_ID = 28;
+    private static final int PEOPLE_FILTER = 29;
+    private static final int DELETED_PEOPLE = 30;
+    private static final int DELETED_GROUPS = 31;
+    private static final int SEARCH_SUGGESTIONS = 32;
+
+
+
+    private static final String PEOPLE_JOINS =
+            " LEFT OUTER JOIN data name ON (raw_contacts._id = name.raw_contact_id"
+            + " AND (SELECT mimetype FROM mimetypes WHERE mimetypes._id = name.mimetype_id)"
+                    + "='" + StructuredName.CONTENT_ITEM_TYPE + "')"
+            + " LEFT OUTER JOIN data organization ON (raw_contacts._id = organization.raw_contact_id"
+            + " AND (SELECT mimetype FROM mimetypes WHERE mimetypes._id = organization.mimetype_id)"
+                    + "='" + Organization.CONTENT_ITEM_TYPE + "' AND organization.is_primary)"
+            + " LEFT OUTER JOIN data email ON (raw_contacts._id = email.raw_contact_id"
+            + " AND (SELECT mimetype FROM mimetypes WHERE mimetypes._id = email.mimetype_id)"
+                    + "='" + Email.CONTENT_ITEM_TYPE + "' AND email.is_primary)"
+            + " LEFT OUTER JOIN data note ON (raw_contacts._id = note.raw_contact_id"
+            + " AND (SELECT mimetype FROM mimetypes WHERE mimetypes._id = note.mimetype_id)"
+                    + "='" + Note.CONTENT_ITEM_TYPE + "')"
+            + " LEFT OUTER JOIN data phone ON (raw_contacts._id = phone.raw_contact_id"
+            + " AND (SELECT mimetype FROM mimetypes WHERE mimetypes._id = phone.mimetype_id)"
+                    + "='" + Phone.CONTENT_ITEM_TYPE + "' AND phone.is_primary)";
+
+    public static final String DATA_JOINS =
+            " JOIN mimetypes ON (mimetypes._id = data.mimetype_id)"
+            + " JOIN raw_contacts ON (raw_contacts._id = data.raw_contact_id)"
+            + PEOPLE_JOINS;
+
+    public static final String PRESENCE_JOINS =
+            " LEFT OUTER JOIN presence ON ("
+            + " presence.presence_id = (SELECT max(presence_id) FROM presence"
+            + " WHERE view_v1_people._id = presence.raw_contact_id))";
+
+    private static final String PHONETIC_NAME_SQL = "trim(trim("
+            + "ifnull(name." + StructuredName.PHONETIC_GIVEN_NAME + ",' ')||' '||"
+            + "ifnull(name." + StructuredName.PHONETIC_MIDDLE_NAME + ",' '))||' '||"
+            + "ifnull(name." + StructuredName.PHONETIC_FAMILY_NAME + ",' ')) ";
+
+    private static final String CONTACT_METHOD_KIND_SQL =
+            "CAST ((CASE WHEN mimetype='" + Email.CONTENT_ITEM_TYPE + "'"
+                + " THEN " + android.provider.Contacts.KIND_EMAIL
+                + " ELSE"
+                    + " (CASE WHEN mimetype='" + Im.CONTENT_ITEM_TYPE +"'"
+                        + " THEN " + android.provider.Contacts.KIND_IM
+                        + " ELSE"
+                        + " (CASE WHEN mimetype='" + StructuredPostal.CONTENT_ITEM_TYPE + "'"
+                            + " THEN "  + android.provider.Contacts.KIND_POSTAL
+                            + " ELSE"
+                                + " NULL"
+                            + " END)"
+                        + " END)"
+                + " END) AS INTEGER)";
+
+    public interface LegacyTables {
+        public static final String PEOPLE = "view_v1_people";
+        public static final String PEOPLE_JOIN_PRESENCE = "view_v1_people" + PRESENCE_JOINS;
+        public static final String GROUPS = "view_v1_groups";
+        public static final String ORGANIZATIONS = "view_v1_organizations";
+        public static final String CONTACT_METHODS = "view_v1_contact_methods";
+        public static final String PHONES = "view_v1_phones";
+        public static final String EXTENSIONS = "view_v1_extensions";
+        public static final String GROUP_MEMBERSHIP = "view_v1_group_membership";
+        public static final String PHOTOS = "view_v1_photos";
+        public static final String PRESENCE_JOIN_CONTACTS = Tables.PRESENCE +
+                " LEFT OUTER JOIN " + Tables.RAW_CONTACTS
+                + " ON (" + Tables.PRESENCE + "." + Presence.RAW_CONTACT_ID + "="
+                + RawContactsColumns.CONCRETE_ID + ")";
+    }
+
+    private static final String[] ORGANIZATION_MIME_TYPES = new String[] {
+        Organization.CONTENT_ITEM_TYPE
+    };
+
+    private static final String[] CONTACT_METHOD_MIME_TYPES = new String[] {
+        Email.CONTENT_ITEM_TYPE,
+        Im.CONTENT_ITEM_TYPE,
+        StructuredPostal.CONTENT_ITEM_TYPE,
+    };
+
+    private static final String[] PHONE_MIME_TYPES = new String[] {
+        Phone.CONTENT_ITEM_TYPE
+    };
+
+    private interface PhotoQuery {
+        String[] COLUMNS = { Data._ID };
+
+        int _ID = 0;
+    }
+
+    /**
+     * A custom data row that is used to store legacy photo data fields no
+     * longer directly supported by the API.
+     */
+    private interface LegacyPhotoData {
+        public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/photo_v1_extras";
+
+        public static final String PHOTO_DATA_ID = Data.DATA1;
+        public static final String LOCAL_VERSION = Data.DATA2;
+        public static final String DOWNLOAD_REQUIRED = Data.DATA3;
+        public static final String EXISTS_ON_SERVER = Data.DATA4;
+        public static final String SYNC_ERROR = Data.DATA5;
+    }
+
+    public static final String LEGACY_PHOTO_JOIN =
+            " LEFT OUTER JOIN data legacy_photo ON (raw_contacts._id = legacy_photo.raw_contact_id"
+            + " AND (SELECT mimetype FROM mimetypes WHERE mimetypes._id = legacy_photo.mimetype_id)"
+                + "='" + LegacyPhotoData.CONTENT_ITEM_TYPE + "'"
+            + " AND " + DataColumns.CONCRETE_ID + " = legacy_photo." + LegacyPhotoData.PHOTO_DATA_ID
+            + ")";
+
+    private static final HashMap<String, String> sPeopleProjectionMap;
+    private static final HashMap<String, String> sOrganizationProjectionMap;
+    private static final HashMap<String, String> sContactMethodProjectionMap;
+    private static final HashMap<String, String> sPhoneProjectionMap;
+    private static final HashMap<String, String> sExtensionProjectionMap;
+    private static final HashMap<String, String> sGroupProjectionMap;
+    private static final HashMap<String, String> sGroupMembershipProjectionMap;
+    private static final HashMap<String, String> sPhotoProjectionMap;
+    private static final HashMap<String, String> sPresenceProjectionMap;
+
+    static {
+
+        // Contacts URI matching table
+        UriMatcher matcher = sUriMatcher;
+
+        String authority = android.provider.Contacts.AUTHORITY;
+        matcher.addURI(authority, "extensions", EXTENSIONS);
+        matcher.addURI(authority, "extensions/#", EXTENSIONS_ID);
+        matcher.addURI(authority, "groups", GROUPS);
+        matcher.addURI(authority, "groups/#", GROUPS_ID);
+//        matcher.addURI(authority, "groups/name/*/members", GROUP_NAME_MEMBERS);
+//        matcher.addURI(authority, "groups/name/*/members/filter/*",
+//                GROUP_NAME_MEMBERS_FILTER);
+//        matcher.addURI(authority, "groups/system_id/*/members", GROUP_SYSTEM_ID_MEMBERS);
+//        matcher.addURI(authority, "groups/system_id/*/members/filter/*",
+//                GROUP_SYSTEM_ID_MEMBERS_FILTER);
+        matcher.addURI(authority, "groupmembership", GROUPMEMBERSHIP);
+        matcher.addURI(authority, "groupmembership/#", GROUPMEMBERSHIP_ID);
+//        matcher.addURI(authority, "groupmembershipraw", GROUPMEMBERSHIP_RAW);
+        matcher.addURI(authority, "people", PEOPLE);
+//        matcher.addURI(authority, "people/strequent", PEOPLE_STREQUENT);
+//        matcher.addURI(authority, "people/strequent/filter/*", PEOPLE_STREQUENT_FILTER);
+        matcher.addURI(authority, "people/filter/*", PEOPLE_FILTER);
+//        matcher.addURI(authority, "people/with_phones_filter/*",
+//                PEOPLE_WITH_PHONES_FILTER);
+//        matcher.addURI(authority, "people/with_email_or_im_filter/*",
+//                PEOPLE_WITH_EMAIL_OR_IM_FILTER);
+        matcher.addURI(authority, "people/#", PEOPLE_ID);
+        matcher.addURI(authority, "people/#/extensions", PEOPLE_EXTENSIONS);
+        matcher.addURI(authority, "people/#/extensions/#", PEOPLE_EXTENSIONS_ID);
+        matcher.addURI(authority, "people/#/phones", PEOPLE_PHONES);
+        matcher.addURI(authority, "people/#/phones/#", PEOPLE_PHONES_ID);
+//        matcher.addURI(authority, "people/#/phones_with_presence",
+//                PEOPLE_PHONES_WITH_PRESENCE);
+        matcher.addURI(authority, "people/#/photo", PEOPLE_PHOTO);
+//        matcher.addURI(authority, "people/#/photo/data", PEOPLE_PHOTO_DATA);
+        matcher.addURI(authority, "people/#/contact_methods", PEOPLE_CONTACTMETHODS);
+//        matcher.addURI(authority, "people/#/contact_methods_with_presence",
+//                PEOPLE_CONTACTMETHODS_WITH_PRESENCE);
+        matcher.addURI(authority, "people/#/contact_methods/#", PEOPLE_CONTACTMETHODS_ID);
+//        matcher.addURI(authority, "people/#/organizations", PEOPLE_ORGANIZATIONS);
+//        matcher.addURI(authority, "people/#/organizations/#", PEOPLE_ORGANIZATIONS_ID);
+        matcher.addURI(authority, "people/#/groupmembership", PEOPLE_GROUPMEMBERSHIP);
+        matcher.addURI(authority, "people/#/groupmembership/#", PEOPLE_GROUPMEMBERSHIP_ID);
+//        matcher.addURI(authority, "people/raw", PEOPLE_RAW);
+//        matcher.addURI(authority, "people/owner", PEOPLE_OWNER);
+        matcher.addURI(authority, "people/#/update_contact_time",
+                PEOPLE_UPDATE_CONTACT_TIME);
+        matcher.addURI(authority, "deleted_people", DELETED_PEOPLE);
+        matcher.addURI(authority, "deleted_groups", DELETED_GROUPS);
+        matcher.addURI(authority, "phones", PHONES);
+//        matcher.addURI(authority, "phones_with_presence", PHONES_WITH_PRESENCE);
+//        matcher.addURI(authority, "phones/filter/*", PHONES_FILTER);
+//        matcher.addURI(authority, "phones/filter_name/*", PHONES_FILTER_NAME);
+//        matcher.addURI(authority, "phones/mobile_filter_name/*",
+//                PHONES_MOBILE_FILTER_NAME);
+        matcher.addURI(authority, "phones/#", PHONES_ID);
+        matcher.addURI(authority, "photos", PHOTOS);
+        matcher.addURI(authority, "photos/#", PHOTOS_ID);
+        matcher.addURI(authority, "contact_methods", CONTACTMETHODS);
+//        matcher.addURI(authority, "contact_methods/email", CONTACTMETHODS_EMAIL);
+//        matcher.addURI(authority, "contact_methods/email/*", CONTACTMETHODS_EMAIL_FILTER);
+        matcher.addURI(authority, "contact_methods/#", CONTACTMETHODS_ID);
+//        matcher.addURI(authority, "contact_methods/with_presence",
+//                CONTACTMETHODS_WITH_PRESENCE);
+        matcher.addURI(authority, "presence", PRESENCE);
+        matcher.addURI(authority, "presence/#", PRESENCE_ID);
+        matcher.addURI(authority, "organizations", ORGANIZATIONS);
+        matcher.addURI(authority, "organizations/#", ORGANIZATIONS_ID);
+//        matcher.addURI(authority, "voice_dialer_timestamp", VOICE_DIALER_TIMESTAMP);
+        matcher.addURI(authority, SearchManager.SUGGEST_URI_PATH_QUERY,
+                SEARCH_SUGGESTIONS);
+        matcher.addURI(authority, SearchManager.SUGGEST_URI_PATH_QUERY + "/*",
+                SEARCH_SUGGESTIONS);
+//        matcher.addURI(authority, SearchManager.SUGGEST_URI_PATH_SHORTCUT + "/#",
+//                SEARCH_SHORTCUT);
+//        matcher.addURI(authority, "settings", SETTINGS);
+//
+//        matcher.addURI(authority, "live_folders/people", LIVE_FOLDERS_PEOPLE);
+//        matcher.addURI(authority, "live_folders/people/*",
+//                LIVE_FOLDERS_PEOPLE_GROUP_NAME);
+//        matcher.addURI(authority, "live_folders/people_with_phones",
+//                LIVE_FOLDERS_PEOPLE_WITH_PHONES);
+//        matcher.addURI(authority, "live_folders/favorites",
+//                LIVE_FOLDERS_PEOPLE_FAVORITES);
+
+
+        HashMap<String, String> peopleProjectionMap = new HashMap<String, String>();
+        peopleProjectionMap.put(People.NAME, People.NAME);
+        peopleProjectionMap.put(People.DISPLAY_NAME, People.DISPLAY_NAME);
+        peopleProjectionMap.put(People.PHONETIC_NAME, People.PHONETIC_NAME);
+        peopleProjectionMap.put(People.NOTES, People.NOTES);
+        peopleProjectionMap.put(People.TIMES_CONTACTED, People.TIMES_CONTACTED);
+        peopleProjectionMap.put(People.LAST_TIME_CONTACTED, People.LAST_TIME_CONTACTED);
+        peopleProjectionMap.put(People.CUSTOM_RINGTONE, People.CUSTOM_RINGTONE);
+        peopleProjectionMap.put(People.SEND_TO_VOICEMAIL, People.SEND_TO_VOICEMAIL);
+        peopleProjectionMap.put(People.STARRED, People.STARRED);
+
+        sPeopleProjectionMap = new HashMap<String, String>(peopleProjectionMap);
+        sPeopleProjectionMap.put(People.PRIMARY_ORGANIZATION_ID, People.PRIMARY_ORGANIZATION_ID);
+        sPeopleProjectionMap.put(People.PRIMARY_EMAIL_ID, People.PRIMARY_EMAIL_ID);
+        sPeopleProjectionMap.put(People.PRIMARY_PHONE_ID, People.PRIMARY_PHONE_ID);
+        sPeopleProjectionMap.put(People.NUMBER, People.NUMBER);
+        sPeopleProjectionMap.put(People.TYPE, People.TYPE);
+        sPeopleProjectionMap.put(People.LABEL, People.LABEL);
+        sPeopleProjectionMap.put(People.NUMBER_KEY, People.NUMBER_KEY);
+        sPeopleProjectionMap.put(People.IM_PROTOCOL, People.IM_PROTOCOL);
+        sPeopleProjectionMap.put(People.IM_HANDLE, People.IM_HANDLE);
+        sPeopleProjectionMap.put(People.IM_ACCOUNT, People.IM_ACCOUNT);
+        sPeopleProjectionMap.put(People.PRESENCE_STATUS, People.PRESENCE_STATUS);
+        sPeopleProjectionMap.put(People.PRESENCE_CUSTOM_STATUS, People.PRESENCE_CUSTOM_STATUS);
+
+        sOrganizationProjectionMap = new HashMap<String, String>();
+        sOrganizationProjectionMap.put(android.provider.Contacts.Organizations.PERSON_ID,
+                android.provider.Contacts.Organizations.PERSON_ID);
+        sOrganizationProjectionMap.put(android.provider.Contacts.Organizations.ISPRIMARY,
+                android.provider.Contacts.Organizations.ISPRIMARY);
+        sOrganizationProjectionMap.put(android.provider.Contacts.Organizations.COMPANY,
+                android.provider.Contacts.Organizations.COMPANY);
+        sOrganizationProjectionMap.put(android.provider.Contacts.Organizations.TYPE,
+                android.provider.Contacts.Organizations.TYPE);
+        sOrganizationProjectionMap.put(android.provider.Contacts.Organizations.LABEL,
+                android.provider.Contacts.Organizations.LABEL);
+        sOrganizationProjectionMap.put(android.provider.Contacts.Organizations.TITLE,
+                android.provider.Contacts.Organizations.TITLE);
+
+        sContactMethodProjectionMap = new HashMap<String, String>(peopleProjectionMap);
+        sContactMethodProjectionMap.put(ContactMethods.PERSON_ID, ContactMethods.PERSON_ID);
+        sContactMethodProjectionMap.put(ContactMethods.KIND, ContactMethods.KIND);
+        sContactMethodProjectionMap.put(ContactMethods.ISPRIMARY, ContactMethods.ISPRIMARY);
+        sContactMethodProjectionMap.put(ContactMethods.TYPE, ContactMethods.TYPE);
+        sContactMethodProjectionMap.put(ContactMethods.DATA, ContactMethods.DATA);
+        sContactMethodProjectionMap.put(ContactMethods.LABEL, ContactMethods.LABEL);
+        sContactMethodProjectionMap.put(ContactMethods.AUX_DATA, ContactMethods.AUX_DATA);
+
+        sPhoneProjectionMap = new HashMap<String, String>(peopleProjectionMap);
+        sPhoneProjectionMap.put(android.provider.Contacts.Phones.PERSON_ID,
+                android.provider.Contacts.Phones.PERSON_ID);
+        sPhoneProjectionMap.put(android.provider.Contacts.Phones.ISPRIMARY,
+                android.provider.Contacts.Phones.ISPRIMARY);
+        sPhoneProjectionMap.put(android.provider.Contacts.Phones.NUMBER,
+                android.provider.Contacts.Phones.NUMBER);
+        sPhoneProjectionMap.put(android.provider.Contacts.Phones.TYPE,
+                android.provider.Contacts.Phones.TYPE);
+        sPhoneProjectionMap.put(android.provider.Contacts.Phones.LABEL,
+                android.provider.Contacts.Phones.LABEL);
+        sPhoneProjectionMap.put(android.provider.Contacts.Phones.NUMBER_KEY,
+                android.provider.Contacts.Phones.NUMBER_KEY);
+
+        sExtensionProjectionMap = new HashMap<String, String>();
+        sExtensionProjectionMap.put(android.provider.Contacts.Extensions.PERSON_ID,
+                android.provider.Contacts.Extensions.PERSON_ID);
+        sExtensionProjectionMap.put(android.provider.Contacts.Extensions.NAME,
+                android.provider.Contacts.Extensions.NAME);
+        sExtensionProjectionMap.put(android.provider.Contacts.Extensions.VALUE,
+                android.provider.Contacts.Extensions.VALUE);
+
+        sGroupProjectionMap = new HashMap<String, String>();
+        sGroupProjectionMap.put(android.provider.Contacts.Groups._ID,
+                android.provider.Contacts.Groups._ID);
+        sGroupProjectionMap.put(android.provider.Contacts.Groups.NAME,
+                android.provider.Contacts.Groups.NAME);
+        sGroupProjectionMap.put(android.provider.Contacts.Groups.NOTES,
+                android.provider.Contacts.Groups.NOTES);
+        sGroupProjectionMap.put(android.provider.Contacts.Groups.SYSTEM_ID,
+                android.provider.Contacts.Groups.SYSTEM_ID);
+
+        sGroupMembershipProjectionMap = new HashMap<String, String>();
+        sGroupMembershipProjectionMap.put(android.provider.Contacts.GroupMembership.PERSON_ID,
+                android.provider.Contacts.GroupMembership.PERSON_ID);
+        sGroupMembershipProjectionMap.put(android.provider.Contacts.GroupMembership.GROUP_ID,
+                android.provider.Contacts.GroupMembership.GROUP_ID);
+
+        sPhotoProjectionMap = new HashMap<String, String>();
+        sPhotoProjectionMap.put(android.provider.Contacts.Photos.PERSON_ID,
+                android.provider.Contacts.Photos.PERSON_ID);
+        sPhotoProjectionMap.put(android.provider.Contacts.Photos.DATA,
+                android.provider.Contacts.Photos.DATA);
+        sPhotoProjectionMap.put(android.provider.Contacts.Photos.LOCAL_VERSION,
+                android.provider.Contacts.Photos.LOCAL_VERSION);
+        sPhotoProjectionMap.put(android.provider.Contacts.Photos.DOWNLOAD_REQUIRED,
+                android.provider.Contacts.Photos.DOWNLOAD_REQUIRED);
+        sPhotoProjectionMap.put(android.provider.Contacts.Photos.EXISTS_ON_SERVER,
+                android.provider.Contacts.Photos.EXISTS_ON_SERVER);
+        sPhotoProjectionMap.put(android.provider.Contacts.Photos.SYNC_ERROR,
+                android.provider.Contacts.Photos.SYNC_ERROR);
+
+        sPresenceProjectionMap = new HashMap<String, String>();
+        sPresenceProjectionMap.put(android.provider.Contacts.Presence._ID,
+                Tables.PRESENCE + "." + Presence._ID
+                        + " AS " + android.provider.Contacts.Presence._ID);
+        sPresenceProjectionMap.put(android.provider.Contacts.Presence.PERSON_ID,
+                Tables.PRESENCE + "." + Presence.RAW_CONTACT_ID
+                        + " AS " + android.provider.Contacts.Presence.PERSON_ID);
+        sPresenceProjectionMap.put(android.provider.Contacts.Presence.IM_PROTOCOL,
+                Presence.IM_PROTOCOL
+                    + " AS " + android.provider.Contacts.Presence.IM_PROTOCOL);
+        sPresenceProjectionMap.put(android.provider.Contacts.Presence.IM_HANDLE,
+                Presence.IM_HANDLE
+                    + " AS " + android.provider.Contacts.Presence.IM_HANDLE);
+        sPresenceProjectionMap.put(android.provider.Contacts.Presence.IM_ACCOUNT,
+                Presence.IM_ACCOUNT
+                    + " AS " + android.provider.Contacts.Presence.IM_ACCOUNT);
+        sPresenceProjectionMap.put(android.provider.Contacts.Presence.PRESENCE_STATUS,
+                Presence.PRESENCE_STATUS
+                    + " AS " + android.provider.Contacts.Presence.PRESENCE_STATUS);
+        sPresenceProjectionMap.put(android.provider.Contacts.Presence.PRESENCE_CUSTOM_STATUS,
+                Presence.PRESENCE_CUSTOM_STATUS
+                    + " AS " + android.provider.Contacts.Presence.PRESENCE_CUSTOM_STATUS);
+    }
+
+    private final Context mContext;
+    private final OpenHelper mOpenHelper;
+    private final ContactsProvider2 mContactsProvider;
+    private final NameSplitter mPhoneticNameSplitter;
+    private final GlobalSearchSupport mGlobalSearchSupport;
+
+    /** Precompiled sql statement for incrementing times contacted for a contact */
+    private final SQLiteStatement mLastTimeContactedUpdate;
+
+    private final ContentValues mValues = new ContentValues();
+
+
+    public LegacyApiSupport(Context context, OpenHelper openHelper,
+            ContactsProvider2 contactsProvider, GlobalSearchSupport globalSearchSupport) {
+        mContext = context;
+        mContactsProvider = contactsProvider;
+        mOpenHelper = openHelper;
+        mGlobalSearchSupport = globalSearchSupport;
+        mOpenHelper.setDelegate(this);
+
+        mPhoneticNameSplitter = new NameSplitter("", "", "",
+                context.getString(com.android.internal.R.string.common_name_conjunctions));
+
+        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+        mLastTimeContactedUpdate = db.compileStatement("UPDATE " + Tables.RAW_CONTACTS + " SET "
+                + RawContacts.TIMES_CONTACTED + "="
+                + RawContacts.TIMES_CONTACTED + "+1,"
+                + RawContacts.LAST_TIME_CONTACTED + "=? WHERE "
+                + RawContacts._ID + "=?");
+    }
+
+
+    public void createDatabase(SQLiteDatabase db) {
+
+        db.execSQL("DROP VIEW IF EXISTS " + LegacyTables.PEOPLE + ";");
+        db.execSQL("CREATE VIEW " + LegacyTables.PEOPLE + " AS SELECT " +
+                RawContactsColumns.CONCRETE_ID
+                        + " AS " + android.provider.Contacts.People._ID + ", " +
+                "name." + StructuredName.DISPLAY_NAME
+                        + " AS " + People.NAME + ", " +
+                Tables.RAW_CONTACTS + "." + RawContactsColumns.DISPLAY_NAME
+                        + " AS " + People.DISPLAY_NAME + ", " +
+                PHONETIC_NAME_SQL
+                        + " AS " + People.PHONETIC_NAME + " , " +
+                "note." + Note.NOTE
+                        + " AS " + People.NOTES + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.TIMES_CONTACTED
+                        + " AS " + People.TIMES_CONTACTED + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.LAST_TIME_CONTACTED
+                        + " AS " + People.LAST_TIME_CONTACTED + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.CUSTOM_RINGTONE
+                        + " AS " + People.CUSTOM_RINGTONE + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.SEND_TO_VOICEMAIL
+                        + " AS " + People.SEND_TO_VOICEMAIL + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.STARRED
+                        + " AS " + People.STARRED + ", " +
+                "organization." + Data._ID
+                        + " AS " + People.PRIMARY_ORGANIZATION_ID + ", " +
+                "email." + Data._ID
+                        + " AS " + People.PRIMARY_EMAIL_ID + ", " +
+                "phone." + Data._ID
+                        + " AS " + People.PRIMARY_PHONE_ID + ", " +
+                "phone." + Phone.NUMBER
+                        + " AS " + People.NUMBER + ", " +
+                "phone." + Phone.TYPE
+                        + " AS " + People.TYPE + ", " +
+                "phone." + Phone.LABEL
+                        + " AS " + People.LABEL + ", " +
+                "phone." + PhoneColumns.NORMALIZED_NUMBER
+                        + " AS " + People.NUMBER_KEY + ", " +
+                RawContacts.IS_RESTRICTED +
+                " FROM " + Tables.RAW_CONTACTS + PEOPLE_JOINS +
+                " WHERE " + Tables.RAW_CONTACTS + "." + RawContacts.DELETED + "=0" +
+        ";");
+
+        db.execSQL("DROP VIEW IF EXISTS " + LegacyTables.ORGANIZATIONS + ";");
+        db.execSQL("CREATE VIEW " + LegacyTables.ORGANIZATIONS + " AS SELECT " +
+                DataColumns.CONCRETE_ID
+                        + " AS " + android.provider.Contacts.Organizations._ID + ", " +
+                Data.RAW_CONTACT_ID
+                        + " AS " + android.provider.Contacts.Organizations.PERSON_ID + ", " +
+                Data.IS_PRIMARY
+                        + " AS " + android.provider.Contacts.Organizations.ISPRIMARY + ", " +
+                Organization.COMPANY
+                        + " AS " + android.provider.Contacts.Organizations.COMPANY + ", " +
+                Organization.TYPE
+                        + " AS " + android.provider.Contacts.Organizations.TYPE + ", " +
+                Organization.LABEL
+                        + " AS " + android.provider.Contacts.Organizations.LABEL + ", " +
+                Organization.TITLE
+                        + " AS " + android.provider.Contacts.Organizations.TITLE + ", " +
+                RawContacts.IS_RESTRICTED +
+                " FROM " + Tables.DATA_JOIN_MIMETYPE_RAW_CONTACTS +
+                " WHERE " + MimetypesColumns.CONCRETE_MIMETYPE + "='"
+                        + Organization.CONTENT_ITEM_TYPE + "'"
+                        + " AND " + Tables.RAW_CONTACTS + "." + RawContacts.DELETED + "=0" +
+        ";");
+
+        db.execSQL("DROP VIEW IF EXISTS " + LegacyTables.CONTACT_METHODS + ";");
+        db.execSQL("CREATE VIEW " + LegacyTables.CONTACT_METHODS + " AS SELECT " +
+                DataColumns.CONCRETE_ID
+                        + " AS " + ContactMethods._ID + ", " +
+                DataColumns.CONCRETE_RAW_CONTACT_ID
+                        + " AS " + ContactMethods.PERSON_ID + ", " +
+                CONTACT_METHOD_KIND_SQL
+                        + " AS " + ContactMethods.KIND + ", " +
+                DataColumns.CONCRETE_IS_PRIMARY
+                        + " AS " + ContactMethods.ISPRIMARY + ", " +
+                DataColumns.CONCRETE_DATA1
+                        + " AS " + ContactMethods.TYPE + ", " +
+                DataColumns.CONCRETE_DATA2
+                        + " AS " + ContactMethods.DATA + ", " +
+                DataColumns.CONCRETE_DATA3
+                        + " AS " + ContactMethods.LABEL + ", " +
+                DataColumns.CONCRETE_DATA14
+                        + " AS " + ContactMethods.AUX_DATA + ", " +
+                "name." + StructuredName.DISPLAY_NAME
+                        + " AS " + ContactMethods.NAME + ", " +
+                Tables.RAW_CONTACTS + "." + RawContactsColumns.DISPLAY_NAME
+                        + " AS " + ContactMethods.DISPLAY_NAME + ", " +
+                PHONETIC_NAME_SQL
+                        + " AS " + ContactMethods.PHONETIC_NAME + " , " +
+                "note." + Note.NOTE
+                        + " AS " + ContactMethods.NOTES + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.TIMES_CONTACTED
+                        + " AS " + ContactMethods.TIMES_CONTACTED + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.LAST_TIME_CONTACTED
+                        + " AS " + ContactMethods.LAST_TIME_CONTACTED + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.CUSTOM_RINGTONE
+                        + " AS " + ContactMethods.CUSTOM_RINGTONE + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.SEND_TO_VOICEMAIL
+                        + " AS " + ContactMethods.SEND_TO_VOICEMAIL + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.STARRED
+                        + " AS " + ContactMethods.STARRED + ", " +
+                RawContacts.IS_RESTRICTED +
+                " FROM " + Tables.DATA + DATA_JOINS +
+                " WHERE " + ContactMethods.KIND + " IS NOT NULL"
+                    + " AND " + Tables.RAW_CONTACTS + "." + RawContacts.DELETED + "=0" +
+        ";");
+
+
+        db.execSQL("DROP VIEW IF EXISTS " + LegacyTables.PHONES + ";");
+        db.execSQL("CREATE VIEW " + LegacyTables.PHONES + " AS SELECT " +
+                DataColumns.CONCRETE_ID
+                        + " AS " + android.provider.Contacts.Phones._ID + ", " +
+                DataColumns.CONCRETE_RAW_CONTACT_ID
+                        + " AS " + android.provider.Contacts.Phones.PERSON_ID + ", " +
+                DataColumns.CONCRETE_IS_PRIMARY
+                        + " AS " + android.provider.Contacts.Phones.ISPRIMARY + ", " +
+                Tables.DATA + "." + Phone.NUMBER
+                        + " AS " + android.provider.Contacts.Phones.NUMBER + ", " +
+                Tables.DATA + "." + Phone.TYPE
+                        + " AS " + android.provider.Contacts.Phones.TYPE + ", " +
+                Tables.DATA + "." + Phone.LABEL
+                        + " AS " + android.provider.Contacts.Phones.LABEL + ", " +
+                PhoneColumns.CONCRETE_NORMALIZED_NUMBER
+                        + " AS " + android.provider.Contacts.Phones.NUMBER_KEY + ", " +
+                "name." + StructuredName.DISPLAY_NAME
+                        + " AS " + android.provider.Contacts.Phones.NAME + ", " +
+                Tables.RAW_CONTACTS + "." + RawContactsColumns.DISPLAY_NAME
+                        + " AS " + android.provider.Contacts.Phones.DISPLAY_NAME + ", " +
+                PHONETIC_NAME_SQL
+                        + " AS " + android.provider.Contacts.Phones.PHONETIC_NAME + " , " +
+                "note." + Note.NOTE
+                        + " AS " + android.provider.Contacts.Phones.NOTES + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.TIMES_CONTACTED
+                        + " AS " + android.provider.Contacts.Phones.TIMES_CONTACTED + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.LAST_TIME_CONTACTED
+                        + " AS " + android.provider.Contacts.Phones.LAST_TIME_CONTACTED + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.CUSTOM_RINGTONE
+                        + " AS " + android.provider.Contacts.Phones.CUSTOM_RINGTONE + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.SEND_TO_VOICEMAIL
+                        + " AS " + android.provider.Contacts.Phones.SEND_TO_VOICEMAIL + ", " +
+                Tables.RAW_CONTACTS + "." + RawContacts.STARRED
+                        + " AS " + android.provider.Contacts.Phones.STARRED + ", " +
+                RawContacts.IS_RESTRICTED +
+                " FROM " + Tables.DATA + DATA_JOINS +
+                " WHERE " + MimetypesColumns.CONCRETE_MIMETYPE + "='"
+                        + Phone.CONTENT_ITEM_TYPE + "'"
+                        + " AND " + Tables.RAW_CONTACTS + "." + RawContacts.DELETED + "=0" +
+        ";");
+
+        db.execSQL("DROP VIEW IF EXISTS " + LegacyTables.EXTENSIONS + ";");
+        db.execSQL("CREATE VIEW " + LegacyTables.EXTENSIONS + " AS SELECT " +
+                DataColumns.CONCRETE_ID
+                        + " AS " + android.provider.Contacts.Extensions._ID + ", " +
+                DataColumns.CONCRETE_RAW_CONTACT_ID
+                        + " AS " + android.provider.Contacts.Extensions.PERSON_ID + ", " +
+                ExtensionsColumns.NAME
+                        + " AS " + android.provider.Contacts.Extensions.NAME + ", " +
+                ExtensionsColumns.VALUE
+                        + " AS " + android.provider.Contacts.Extensions.VALUE + ", " +
+                RawContacts.IS_RESTRICTED +
+                " FROM " + Tables.DATA_JOIN_MIMETYPE_RAW_CONTACTS +
+                " WHERE " + MimetypesColumns.CONCRETE_MIMETYPE + "='"
+                        + android.provider.Contacts.Extensions.CONTENT_ITEM_TYPE + "'"
+                        + " AND " + Tables.RAW_CONTACTS + "." + RawContacts.DELETED + "=0" +
+        ";");
+
+        db.execSQL("DROP VIEW IF EXISTS " + LegacyTables.GROUPS + ";");
+        db.execSQL("CREATE VIEW " + LegacyTables.GROUPS + " AS SELECT " +
+                GroupsColumns.CONCRETE_ID + " AS " + android.provider.Contacts.Groups._ID + ", " +
+                Groups.TITLE + " AS " + android.provider.Contacts.Groups.NAME + ", " +
+                Groups.NOTES + " AS " + android.provider.Contacts.Groups.NOTES + " , " +
+                Groups.SYSTEM_ID + " AS " + android.provider.Contacts.Groups.SYSTEM_ID +
+                " FROM " + Tables.GROUPS +
+        ";");
+
+        db.execSQL("DROP VIEW IF EXISTS " + LegacyTables.GROUP_MEMBERSHIP + ";");
+        db.execSQL("CREATE VIEW " + LegacyTables.GROUP_MEMBERSHIP + " AS SELECT " +
+                DataColumns.CONCRETE_ID
+                        + " AS " + android.provider.Contacts.GroupMembership._ID + ", " +
+                DataColumns.CONCRETE_RAW_CONTACT_ID
+                        + " AS " + android.provider.Contacts.GroupMembership.PERSON_ID + ", " +
+                GroupMembership.GROUP_ROW_ID
+                        + " AS " + android.provider.Contacts.GroupMembership.GROUP_ID + ", " +
+                Groups.TITLE
+                        + " AS " + android.provider.Contacts.GroupMembership.NAME + ", " +
+                Groups.NOTES
+                        + " AS " + android.provider.Contacts.GroupMembership.NOTES + " , " +
+                Groups.SYSTEM_ID
+                        + " AS " + android.provider.Contacts.GroupMembership.SYSTEM_ID + ", " +
+                RawContacts.IS_RESTRICTED +
+                " FROM " + Tables.DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_GROUPS +
+                " WHERE " + MimetypesColumns.CONCRETE_MIMETYPE + "='"
+                        + GroupMembership.CONTENT_ITEM_TYPE + "'"
+                        + " AND " + Tables.RAW_CONTACTS + "." + RawContacts.DELETED + "=0" +
+        ";");
+
+        db.execSQL("DROP VIEW IF EXISTS " + LegacyTables.PHOTOS + ";");
+        db.execSQL("CREATE VIEW " + LegacyTables.PHOTOS + " AS SELECT " +
+                DataColumns.CONCRETE_ID
+                        + " AS " + android.provider.Contacts.Photos._ID + ", " +
+                DataColumns.CONCRETE_RAW_CONTACT_ID
+                        + " AS " + android.provider.Contacts.Photos.PERSON_ID + ", " +
+                Tables.DATA + "." + Photo.PHOTO
+                        + " AS " + android.provider.Contacts.Photos.DATA + ", " +
+                "legacy_photo." + LegacyPhotoData.EXISTS_ON_SERVER
+                        + " AS " + android.provider.Contacts.Photos.EXISTS_ON_SERVER + ", " +
+                "legacy_photo." + LegacyPhotoData.DOWNLOAD_REQUIRED
+                        + " AS " + android.provider.Contacts.Photos.DOWNLOAD_REQUIRED + ", " +
+                "legacy_photo." + LegacyPhotoData.LOCAL_VERSION
+                        + " AS " + android.provider.Contacts.Photos.LOCAL_VERSION + ", " +
+                "legacy_photo." + LegacyPhotoData.SYNC_ERROR
+                        + " AS " + android.provider.Contacts.Photos.SYNC_ERROR + ", " +
+                RawContacts.IS_RESTRICTED +
+                " FROM " + Tables.DATA + DATA_JOINS + LEGACY_PHOTO_JOIN +
+                " WHERE " + MimetypesColumns.CONCRETE_MIMETYPE + "='"
+                        + Photo.CONTENT_ITEM_TYPE + "'"
+                        + " AND " + Tables.RAW_CONTACTS + "." + RawContacts.DELETED + "=0" +
+        ";");
+    }
+
+    public Uri insert(Uri uri, ContentValues values) {
+        final int match = sUriMatcher.match(uri);
+        long id = 0;
+        switch (match) {
+            case PEOPLE:
+                id = insertPeople(values);
+                break;
+
+            case ORGANIZATIONS:
+                id = insertOrganization(values);
+                break;
+
+            case PEOPLE_CONTACTMETHODS: {
+                long rawContactId = Long.parseLong(uri.getPathSegments().get(1));
+                id = insertContactMethod(rawContactId, values);
+                break;
+            }
+
+            case CONTACTMETHODS: {
+                long rawContactId = getRequiredValue(values, ContactMethods.PERSON_ID);
+                id = insertContactMethod(rawContactId, values);
+                break;
+            }
+
+            case PHONES: {
+                long rawContactId = getRequiredValue(values,
+                        android.provider.Contacts.Phones.PERSON_ID);
+                id = insertPhone(rawContactId, values);
+                break;
+            }
+
+            case EXTENSIONS: {
+                long rawContactId = getRequiredValue(values,
+                        android.provider.Contacts.Extensions.PERSON_ID);
+                id = insertExtension(rawContactId, values);
+                break;
+            }
+
+            case GROUPS:
+                id = insertGroup(values);
+                break;
+
+            case GROUPMEMBERSHIP: {
+                long rawContactId = getRequiredValue(values,
+                        android.provider.Contacts.GroupMembership.PERSON_ID);
+                long groupId = getRequiredValue(values,
+                        android.provider.Contacts.GroupMembership.GROUP_ID);
+                id = insertGroupMembership(rawContactId, groupId);
+                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;
+    }
+
+    private long getRequiredValue(ContentValues values, String column) {
+        if (!values.containsKey(column)) {
+            throw new RuntimeException("Required value: " + column);
+        }
+
+        return values.getAsLong(column);
+    }
+
+    private long insertPeople(ContentValues values) {
+        mValues.clear();
+
+        OpenHelper.copyStringValue(mValues, RawContacts.CUSTOM_RINGTONE,
+                values, People.CUSTOM_RINGTONE);
+        OpenHelper.copyLongValue(mValues, RawContacts.SEND_TO_VOICEMAIL,
+                values, People.SEND_TO_VOICEMAIL);
+        OpenHelper.copyLongValue(mValues, RawContacts.LAST_TIME_CONTACTED,
+                values, People.LAST_TIME_CONTACTED);
+        OpenHelper.copyLongValue(mValues, RawContacts.TIMES_CONTACTED,
+                values, People.TIMES_CONTACTED);
+        OpenHelper.copyLongValue(mValues, RawContacts.STARRED,
+                values, People.STARRED);
+        Uri contactUri = mContactsProvider.insert(RawContacts.CONTENT_URI, mValues);
+        long rawContactId = ContentUris.parseId(contactUri);
+
+        if (values.containsKey(People.NAME) || values.containsKey(People.PHONETIC_NAME)) {
+            mValues.clear();
+            mValues.put(ContactsContract.Data.RAW_CONTACT_ID, rawContactId);
+            mValues.put(ContactsContract.Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+            OpenHelper.copyStringValue(mValues, StructuredName.DISPLAY_NAME,
+                    values, People.NAME);
+            if (values.containsKey(People.PHONETIC_NAME)) {
+                String phoneticName = values.getAsString(People.PHONETIC_NAME);
+                NameSplitter.Name parsedName = new NameSplitter.Name();
+                mPhoneticNameSplitter.split(parsedName, phoneticName);
+                mValues.put(StructuredName.PHONETIC_GIVEN_NAME, parsedName.getGivenNames());
+                mValues.put(StructuredName.PHONETIC_MIDDLE_NAME, parsedName.getMiddleName());
+                mValues.put(StructuredName.PHONETIC_FAMILY_NAME, parsedName.getFamilyName());
+            }
+
+            mContactsProvider.insert(ContactsContract.Data.CONTENT_URI, mValues);
+        }
+
+        if (values.containsKey(People.NOTES)) {
+            mValues.clear();
+            mValues.put(Data.RAW_CONTACT_ID, rawContactId);
+            mValues.put(Data.MIMETYPE, Note.CONTENT_ITEM_TYPE);
+            OpenHelper.copyStringValue(mValues, Note.NOTE, values, People.NOTES);
+            mContactsProvider.insert(Data.CONTENT_URI, mValues);
+        }
+
+        // TODO instant aggregation
+        return rawContactId;
+    }
+
+    private long insertOrganization(ContentValues values) {
+        mValues.clear();
+
+        OpenHelper.copyLongValue(mValues, Data.RAW_CONTACT_ID,
+                values, android.provider.Contacts.Organizations.PERSON_ID);
+        mValues.put(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE);
+
+        OpenHelper.copyLongValue(mValues, Data.IS_PRIMARY,
+                values, android.provider.Contacts.Organizations.ISPRIMARY);
+
+        OpenHelper.copyStringValue(mValues, Organization.COMPANY,
+                values, android.provider.Contacts.Organizations.COMPANY);
+
+        // TYPE values happen to remain the same between V1 and V2 - can just copy the value
+        OpenHelper.copyLongValue(mValues, Organization.TYPE,
+                values, android.provider.Contacts.Organizations.TYPE);
+
+        OpenHelper.copyStringValue(mValues, Organization.LABEL,
+                values, android.provider.Contacts.Organizations.LABEL);
+        OpenHelper.copyStringValue(mValues, Organization.TITLE,
+                values, android.provider.Contacts.Organizations.TITLE);
+
+        Uri uri = mContactsProvider.insert(Data.CONTENT_URI, mValues);
+
+        return ContentUris.parseId(uri);
+    }
+
+    private long insertPhone(long rawContactId, ContentValues values) {
+        mValues.clear();
+
+        mValues.put(Data.RAW_CONTACT_ID, rawContactId);
+        mValues.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+
+        OpenHelper.copyLongValue(mValues, Data.IS_PRIMARY,
+                values, android.provider.Contacts.Phones.ISPRIMARY);
+
+        OpenHelper.copyStringValue(mValues, Phone.NUMBER,
+                values, android.provider.Contacts.Phones.NUMBER);
+
+        // TYPE values happen to remain the same between V1 and V2 - can just copy the value
+        OpenHelper.copyLongValue(mValues, Phone.TYPE,
+                values, android.provider.Contacts.Phones.TYPE);
+
+        OpenHelper.copyStringValue(mValues, Phone.LABEL,
+                values, android.provider.Contacts.Phones.LABEL);
+
+        Uri uri = mContactsProvider.insert(Data.CONTENT_URI, mValues);
+
+        return ContentUris.parseId(uri);
+    }
+
+    private long insertContactMethod(long rawContactId, ContentValues values) {
+        Integer kind = values.getAsInteger(ContactMethods.KIND);
+        if (kind == null) {
+            throw new RuntimeException("Required value: " + ContactMethods.KIND);
+        }
+
+        mValues.clear();
+        mValues.put(Data.RAW_CONTACT_ID, rawContactId);
+
+        OpenHelper.copyLongValue(mValues, Data.IS_PRIMARY, values, ContactMethods.ISPRIMARY);
+
+        switch (kind) {
+            case android.provider.Contacts.KIND_EMAIL:
+                copyCommonFields(values, Email.CONTENT_ITEM_TYPE, Email.TYPE, Email.LABEL,
+                        Email.DATA, Data.DATA14);
+                break;
+
+            case android.provider.Contacts.KIND_IM:
+                copyCommonFields(values, Im.CONTENT_ITEM_TYPE, Im.TYPE, Im.LABEL,
+                        Email.DATA, Data.DATA14);
+                break;
+
+            case android.provider.Contacts.KIND_POSTAL:
+                copyCommonFields(values, StructuredPostal.CONTENT_ITEM_TYPE, StructuredPostal.TYPE,
+                        StructuredPostal.LABEL, StructuredPostal.FORMATTED_ADDRESS, Data.DATA14);
+                break;
+        }
+
+        Uri uri = mContactsProvider.insert(Data.CONTENT_URI, mValues);
+        return ContentUris.parseId(uri);
+    }
+
+    private void copyCommonFields(ContentValues values, String mimeType, String typeColumn,
+            String labelColumn, String dataColumn, String auxDataColumn) {
+        mValues.put(Data.MIMETYPE, mimeType);
+        OpenHelper.copyLongValue(mValues, typeColumn, values, ContactMethods.TYPE);
+        OpenHelper.copyStringValue(mValues, labelColumn, values, ContactMethods.LABEL);
+        OpenHelper.copyStringValue(mValues, dataColumn, values, ContactMethods.DATA);
+        OpenHelper.copyStringValue(mValues, auxDataColumn, values, ContactMethods.AUX_DATA);
+    }
+
+    private long insertExtension(long rawContactId, ContentValues values) {
+        mValues.clear();
+
+        mValues.put(Data.RAW_CONTACT_ID, rawContactId);
+        mValues.put(Data.MIMETYPE, android.provider.Contacts.Extensions.CONTENT_ITEM_TYPE);
+
+        OpenHelper.copyStringValue(mValues, ExtensionsColumns.NAME,
+                values, android.provider.Contacts.People.Extensions.NAME);
+        OpenHelper.copyStringValue(mValues, ExtensionsColumns.VALUE,
+                values, android.provider.Contacts.People.Extensions.VALUE);
+
+        Uri uri = mContactsProvider.insert(Data.CONTENT_URI, mValues);
+        return ContentUris.parseId(uri);
+    }
+
+    private long insertGroup(ContentValues values) {
+        mValues.clear();
+
+        OpenHelper.copyStringValue(mValues, Groups.TITLE,
+                values, android.provider.Contacts.Groups.NAME);
+        OpenHelper.copyStringValue(mValues, Groups.NOTES,
+                values, android.provider.Contacts.Groups.NOTES);
+        OpenHelper.copyStringValue(mValues, Groups.SYSTEM_ID,
+                values, android.provider.Contacts.Groups.SYSTEM_ID);
+
+        Uri uri = mContactsProvider.insert(Groups.CONTENT_URI, mValues);
+        return ContentUris.parseId(uri);
+    }
+
+    private long insertGroupMembership(long rawContactId, long groupId) {
+        mValues.clear();
+
+        mValues.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
+        mValues.put(GroupMembership.RAW_CONTACT_ID, rawContactId);
+        mValues.put(GroupMembership.GROUP_ROW_ID, groupId);
+
+        Uri uri = mContactsProvider.insert(Data.CONTENT_URI, mValues);
+        return ContentUris.parseId(uri);
+    }
+
+    private long insertPresence(ContentValues values) {
+        mValues.clear();
+
+        OpenHelper.copyLongValue(mValues, Presence._ID,
+                values, android.provider.Contacts.Presence._ID);
+        OpenHelper.copyLongValue(mValues, Presence.RAW_CONTACT_ID,
+                values, android.provider.Contacts.Presence.PERSON_ID);
+        OpenHelper.copyStringValue(mValues, Presence.IM_PROTOCOL,
+                values, android.provider.Contacts.Presence.IM_PROTOCOL);
+        OpenHelper.copyStringValue(mValues, Presence.IM_HANDLE,
+                values, android.provider.Contacts.Presence.IM_HANDLE);
+        OpenHelper.copyStringValue(mValues, Presence.IM_ACCOUNT,
+                values, android.provider.Contacts.Presence.IM_ACCOUNT);
+        OpenHelper.copyLongValue(mValues, Presence.PRESENCE_STATUS,
+                values, android.provider.Contacts.Presence.PRESENCE_STATUS);
+        OpenHelper.copyStringValue(mValues, Presence.PRESENCE_CUSTOM_STATUS,
+                values, android.provider.Contacts.Presence.PRESENCE_CUSTOM_STATUS);
+
+        return mContactsProvider.insertPresence(mValues);
+    }
+
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        final int match = sUriMatcher.match(uri);
+        int count = 0;
+        switch(match) {
+            case PEOPLE_UPDATE_CONTACT_TIME:
+                count = updateContactTime(uri, values);
+                break;
+
+            case PEOPLE_PHOTO: {
+                long rawContactId = Long.parseLong(uri.getPathSegments().get(1));
+                return updatePhoto(rawContactId, values);
+            }
+
+            case PHOTOS:
+                // TODO
+                break;
+
+            case PHOTOS_ID:
+                // TODO
+                break;
+
+
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+
+        if (count > 0) {
+            mContext.getContentResolver().notifyChange(uri, null);
+        }
+        return count;
+    }
+
+
+    private int updateContactTime(Uri uri, ContentValues values) {
+
+        // TODO check sanctions
+
+        long lastTimeContacted;
+        if (values.containsKey(People.LAST_TIME_CONTACTED)) {
+            lastTimeContacted = values.getAsLong(People.LAST_TIME_CONTACTED);
+        } else {
+            lastTimeContacted = System.currentTimeMillis();
+        }
+
+        long rawContactId = Long.parseLong(uri.getPathSegments().get(1));
+        long contactId = mOpenHelper.getContactId(rawContactId);
+        if (contactId != 0) {
+            mContactsProvider.updateContactTime(contactId, lastTimeContacted);
+        } else {
+            mLastTimeContactedUpdate.bindLong(1, lastTimeContacted);
+            mLastTimeContactedUpdate.bindLong(2, rawContactId);
+            mLastTimeContactedUpdate.execute();
+        }
+        return 1;
+    }
+
+    private int updatePhoto(long rawContactId, ContentValues values) {
+
+        // TODO check sanctions
+
+        int count;
+
+        long dataId = -1;
+        Cursor c = mContactsProvider.query(Data.CONTENT_URI, PhotoQuery.COLUMNS,
+                Data.RAW_CONTACT_ID + "=" + rawContactId + " AND "
+                        + Data.MIMETYPE + "=" + mOpenHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE),
+                null, null);
+        try {
+            if (c.moveToFirst()) {
+                dataId = c.getLong(PhotoQuery._ID);
+            }
+        } finally {
+            c.close();
+        }
+
+        mValues.clear();
+        byte[] bytes = values.getAsByteArray(android.provider.Contacts.Photos.DATA);
+        mValues.put(Photo.PHOTO, bytes);
+
+        if (dataId == -1) {
+            mValues.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE);
+            mValues.put(Data.RAW_CONTACT_ID, rawContactId);
+            Uri dataUri = mContactsProvider.insert(Data.CONTENT_URI, mValues);
+            dataId = ContentUris.parseId(dataUri);
+            count = 1;
+        } else {
+            Uri dataUri = ContentUris.withAppendedId(Data.CONTENT_URI, dataId);
+            count = mContactsProvider.update(dataUri, mValues, null, null);
+        }
+
+        mValues.clear();
+        OpenHelper.copyStringValue(mValues, LegacyPhotoData.LOCAL_VERSION,
+                values, android.provider.Contacts.Photos.LOCAL_VERSION);
+        OpenHelper.copyStringValue(mValues, LegacyPhotoData.DOWNLOAD_REQUIRED,
+                values, android.provider.Contacts.Photos.DOWNLOAD_REQUIRED);
+        OpenHelper.copyStringValue(mValues, LegacyPhotoData.EXISTS_ON_SERVER,
+                values, android.provider.Contacts.Photos.EXISTS_ON_SERVER);
+        OpenHelper.copyStringValue(mValues, LegacyPhotoData.SYNC_ERROR,
+                values, android.provider.Contacts.Photos.SYNC_ERROR);
+
+        int updated = mContactsProvider.update(Data.CONTENT_URI, mValues,
+                Data.MIMETYPE + "='" + LegacyPhotoData.CONTENT_ITEM_TYPE + "'"
+                        + " AND " + Data.RAW_CONTACT_ID + "=" + rawContactId
+                        + " AND " + LegacyPhotoData.PHOTO_DATA_ID + "=" + dataId, null);
+        if (updated == 0) {
+            mValues.put(Data.RAW_CONTACT_ID, rawContactId);
+            mValues.put(Data.MIMETYPE, LegacyPhotoData.CONTENT_ITEM_TYPE);
+            mValues.put(LegacyPhotoData.PHOTO_DATA_ID, dataId);
+            mContactsProvider.insert(Data.CONTENT_URI, mValues);
+        }
+
+        return count;
+    }
+
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        final int match = sUriMatcher.match(uri);
+        int count = 0;
+        switch (match) {
+            case PEOPLE_ID:
+                count = mContactsProvider.deleteRawContact(ContentUris.parseId(uri), false);
+                break;
+
+            case ORGANIZATIONS_ID:
+                count = mContactsProvider.deleteData(ContentUris.parseId(uri),
+                        ORGANIZATION_MIME_TYPES);
+                break;
+
+            case CONTACTMETHODS_ID:
+                count = mContactsProvider.deleteData(ContentUris.parseId(uri),
+                        CONTACT_METHOD_MIME_TYPES);
+                break;
+
+            case PHONES_ID:
+                count = mContactsProvider.deleteData(ContentUris.parseId(uri),
+                        PHONE_MIME_TYPES);
+                break;
+
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+
+        return count;
+    }
+
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder, String limit) {
+        final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        String groupBy = null;
+
+        final int match = sUriMatcher.match(uri);
+        switch (match) {
+            case PEOPLE: {
+                qb.setTables(LegacyTables.PEOPLE_JOIN_PRESENCE);
+                qb.setProjectionMap(sPeopleProjectionMap);
+                break;
+            }
+
+            case PEOPLE_ID:
+                qb.setTables(LegacyTables.PEOPLE_JOIN_PRESENCE);
+                qb.setProjectionMap(sPeopleProjectionMap);
+                qb.appendWhere(People._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case PEOPLE_FILTER: {
+                qb.setTables(LegacyTables.PEOPLE_JOIN_PRESENCE);
+                qb.setProjectionMap(sPeopleProjectionMap);
+                String filterParam = uri.getPathSegments().get(2);
+                qb.appendWhere(People._ID + " IN "
+                        + mContactsProvider.getRawContactsByFilterAsNestedQuery(filterParam));
+                break;
+            }
+
+            case ORGANIZATIONS:
+                qb.setTables(LegacyTables.ORGANIZATIONS);
+                qb.setProjectionMap(sOrganizationProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                break;
+
+            case ORGANIZATIONS_ID:
+                qb.setTables(LegacyTables.ORGANIZATIONS);
+                qb.setProjectionMap(sOrganizationProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.Organizations._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case CONTACTMETHODS:
+                qb.setTables(LegacyTables.CONTACT_METHODS);
+                qb.setProjectionMap(sContactMethodProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                break;
+
+            case CONTACTMETHODS_ID:
+                qb.setTables(LegacyTables.CONTACT_METHODS);
+                qb.setProjectionMap(sContactMethodProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + ContactMethods._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case PEOPLE_CONTACTMETHODS:
+                qb.setTables(LegacyTables.CONTACT_METHODS);
+                qb.setProjectionMap(sContactMethodProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + ContactMethods.PERSON_ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                qb.appendWhere(" AND " + ContactMethods.KIND + " IS NOT NULL");
+                break;
+
+            case PEOPLE_CONTACTMETHODS_ID:
+                qb.setTables(LegacyTables.CONTACT_METHODS);
+                qb.setProjectionMap(sContactMethodProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + ContactMethods.PERSON_ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                qb.appendWhere(" AND " + ContactMethods._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(3));
+                qb.appendWhere(" AND " + ContactMethods.KIND + " IS NOT NULL");
+                break;
+
+            case PHONES:
+                qb.setTables(LegacyTables.PHONES);
+                qb.setProjectionMap(sPhoneProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                break;
+
+            case PHONES_ID:
+                qb.setTables(LegacyTables.PHONES);
+                qb.setProjectionMap(sPhoneProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.Phones._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case PEOPLE_PHONES:
+                qb.setTables(LegacyTables.PHONES);
+                qb.setProjectionMap(sPhoneProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.Phones.PERSON_ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case PEOPLE_PHONES_ID:
+                qb.setTables(LegacyTables.PHONES);
+                qb.setProjectionMap(sPhoneProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.Phones.PERSON_ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                qb.appendWhere(" AND " + android.provider.Contacts.Phones._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(3));
+                break;
+
+            case EXTENSIONS:
+                qb.setTables(LegacyTables.EXTENSIONS);
+                qb.setProjectionMap(sExtensionProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                break;
+
+            case EXTENSIONS_ID:
+                qb.setTables(LegacyTables.EXTENSIONS);
+                qb.setProjectionMap(sExtensionProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.Extensions._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case PEOPLE_EXTENSIONS:
+                qb.setTables(LegacyTables.EXTENSIONS);
+                qb.setProjectionMap(sExtensionProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.Extensions.PERSON_ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case PEOPLE_EXTENSIONS_ID:
+                qb.setTables(LegacyTables.EXTENSIONS);
+                qb.setProjectionMap(sExtensionProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.Extensions.PERSON_ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                qb.appendWhere(" AND " + android.provider.Contacts.Extensions._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(3));
+                break;
+
+            case GROUPS:
+                qb.setTables(LegacyTables.GROUPS);
+                qb.setProjectionMap(sGroupProjectionMap);
+                break;
+
+            case GROUPS_ID:
+                qb.setTables(LegacyTables.GROUPS);
+                qb.setProjectionMap(sGroupProjectionMap);
+                qb.appendWhere(android.provider.Contacts.Groups._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case GROUPMEMBERSHIP:
+                qb.setTables(LegacyTables.GROUP_MEMBERSHIP);
+                qb.setProjectionMap(sGroupMembershipProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                break;
+
+            case GROUPMEMBERSHIP_ID:
+                qb.setTables(LegacyTables.GROUP_MEMBERSHIP);
+                qb.setProjectionMap(sGroupMembershipProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.GroupMembership._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case PEOPLE_GROUPMEMBERSHIP:
+                qb.setTables(LegacyTables.GROUP_MEMBERSHIP);
+                qb.setProjectionMap(sGroupMembershipProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.GroupMembership.PERSON_ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case PEOPLE_GROUPMEMBERSHIP_ID:
+                qb.setTables(LegacyTables.GROUP_MEMBERSHIP);
+                qb.setProjectionMap(sGroupMembershipProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.GroupMembership.PERSON_ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                qb.appendWhere(" AND " + android.provider.Contacts.GroupMembership._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(3));
+                break;
+
+            case PEOPLE_PHOTO:
+                qb.setTables(LegacyTables.PHOTOS);
+                qb.setProjectionMap(sPhotoProjectionMap);
+                mContactsProvider.applyDataRestrictionExceptions(qb);
+                qb.appendWhere(" AND " + android.provider.Contacts.Photos.PERSON_ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                limit = "1";
+                break;
+
+            case PRESENCE:
+                qb.setTables(Tables.PRESENCE);
+                qb.setProjectionMap(sPresenceProjectionMap);
+                qb.appendWhere(mContactsProvider.getContactsRestrictionExceptionAsNestedQuery(
+                        android.provider.Contacts.Presence.PERSON_ID));
+                break;
+
+            case PRESENCE_ID:
+                qb.setTables(Tables.PRESENCE);
+                qb.setProjectionMap(sPresenceProjectionMap);
+                qb.appendWhere(mContactsProvider.getContactsRestrictionExceptionAsNestedQuery(
+                        android.provider.Contacts.Presence.PERSON_ID));
+                qb.appendWhere(" AND " + android.provider.Contacts.Presence._ID + "=");
+                qb.appendWhere(uri.getPathSegments().get(1));
+                break;
+
+            case SEARCH_SUGGESTIONS:
+
+                // No legacy compatibility for search suggestions
+                return mGlobalSearchSupport.handleSearchSuggestionsQuery(db, uri, limit);
+
+            case DELETED_PEOPLE:
+            case DELETED_GROUPS:
+                throw new UnsupportedOperationException();
+
+            default:
+                throw new IllegalArgumentException("Unknown URL " + 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(mContext.getContentResolver(), RawContacts.CONTENT_URI);
+        }
+        DatabaseUtils.dumpCursor(c);
+        return c;
+    }
+
+    /**
+     * Called when a change has been made.
+     *
+     * @param uri the uri that the change was made to
+     */
+    private void onChange(Uri uri) {
+        mContext.getContentResolver().notifyChange(android.provider.Contacts.CONTENT_URI, null);
+    }
+}
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..01c41d4
--- /dev/null
+++ b/src/com/android/providers/contacts/OpenHelper.java
@@ -0,0 +1,1178 @@
+/*
+ * 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.content.SyncStateContentProviderHelper;
+
+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.provider.BaseColumns;
+import android.provider.CallLog.Calls;
+import android.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Groups;
+import android.provider.ContactsContract.Presence;
+import android.provider.ContactsContract.RawContacts;
+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.provider.SocialContract.Activities;
+import android.telephony.PhoneNumberUtils;
+import android.util.Log;
+
+import java.util.HashMap;
+
+/**
+ * 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 = 58;
+    private static final String DATABASE_NAME = "contacts2.db";
+    private static final String DATABASE_PRESENCE = "presence_db";
+
+    public interface Delegate {
+        void createDatabase(SQLiteDatabase db);
+    }
+
+    public interface Tables {
+        public static final String ACCOUNTS = "accounts";
+        public static final String CONTACTS = "contacts";
+        public static final String RAW_CONTACTS = "raw_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 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 CALLS = "calls";
+        public static final String CONTACT_ENTITIES = "contact_entities_view";
+
+        public static final String CONTACTS_JOIN_PRESENCE_PRIMARY_PHONE = "contacts "
+                + "LEFT OUTER JOIN raw_contacts ON (contacts._id = raw_contacts.contact_id) "
+                + "LEFT OUTER JOIN presence ON (raw_contacts._id = presence.raw_contact_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_RAW_CONTACTS = "data "
+                + "JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id)";
+
+        public static final String DATA_JOIN_RAW_CONTACTS_GROUPS = "data "
+                + "LEFT OUTER JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id)"
+                + "LEFT OUTER JOIN groups ON (groups._id = data." + GroupMembership.GROUP_ROW_ID
+                + ")";
+
+        public static final String DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS = "data "
+                + "LEFT OUTER JOIN packages ON (data.package_id = packages._id) "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id)";
+
+        public static final String DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS = "data "
+                + "LEFT OUTER JOIN packages ON (data.package_id = packages._id) "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id) "
+                + "LEFT OUTER JOIN contacts ON (raw_contacts.contact_id = contacts._id)";
+
+        public static final String RAW_CONTACTS_JOIN_CONTACTS = "raw_contacts "
+                + "LEFT OUTER JOIN contacts ON (raw_contacts.contact_id = contacts._id)";
+
+        public static final String DATA_JOIN_MIMETYPES_RAW_CONTACTS_CONTACTS = "data "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id) "
+                + "LEFT OUTER JOIN contacts ON (raw_contacts.contact_id = contacts._id)";
+
+        public static final String DATA_INNER_JOIN_MIMETYPES_RAW_CONTACTS_CONTACTS = "data "
+                + "JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id) "
+                + "JOIN contacts ON (raw_contacts.contact_id = contacts._id)";
+
+        public static final String DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS_GROUPS =
+                "data "
+                + "LEFT OUTER JOIN packages ON (data.package_id = packages._id) "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN groups "
+                + "  ON (mimetypes.mimetype='" + GroupMembership.CONTENT_ITEM_TYPE + "' "
+                + "      AND groups._id = data." + GroupMembership.GROUP_ROW_ID + ") "
+                + "LEFT OUTER JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id) "
+                + "LEFT OUTER JOIN contacts ON (raw_contacts.contact_id = contacts._id)";
+
+        public static final String DATA_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_GROUPS = "data "
+                + "LEFT OUTER JOIN packages ON (data.package_id = packages._id) "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id) "
+                + "LEFT OUTER JOIN groups "
+                + "  ON (mimetypes.mimetype='" + GroupMembership.CONTENT_ITEM_TYPE + "' "
+                + "      AND 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 GROUPS_JOIN_PACKAGES_DATA_RAW_CONTACTS_CONTACTS = "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 raw_contacts ON (data.raw_contact_id = raw_contacts._id) "
+                + "LEFT OUTER JOIN contacts ON (raw_contacts.contact_id = contacts._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_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS =
+                "activities "
+                + "LEFT OUTER JOIN packages ON (activities.package_id = packages._id) "
+                + "LEFT OUTER JOIN mimetypes ON (activities.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN raw_contacts ON (activities.author_contact_id = " +
+                		"raw_contacts._id) "
+                + "LEFT OUTER JOIN contacts ON (raw_contacts.contact_id = contacts._id)";
+
+        public static final String NAME_LOOKUP_JOIN_RAW_CONTACTS = "name_lookup "
+                + "INNER JOIN raw_contacts ON (name_lookup.raw_contact_id = raw_contacts._id)";
+
+        public static final String AGGREGATION_EXCEPTIONS_JOIN_RAW_CONTACTS = "agg_exceptions "
+                + "INNER JOIN raw_contacts raw_contacts1 "
+                + "ON (agg_exceptions.raw_contact_id1 = raw_contacts1._id) ";
+
+        public static final String AGGREGATION_EXCEPTIONS_JOIN_RAW_CONTACTS_TWICE =
+                "agg_exceptions "
+                + "INNER JOIN raw_contacts raw_contacts1 "
+                + "ON (agg_exceptions.raw_contact_id1 = raw_contacts1._id) "
+                + "INNER JOIN raw_contacts raw_contacts2 "
+                + "ON (agg_exceptions.raw_contact_id2 = raw_contacts2._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 = "("
+                + ContactsColumns.OPTIMAL_PRIMARY_PHONE_ID + " IS NOT NULL OR "
+                + ContactsColumns.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_RAW_CONTACTS_GROUPS + " WHERE "
+                + DataColumns.MIMETYPE_ID + "=? AND " + RawContacts.CONTACT_ID + "="
+                + ContactsColumns.CONCRETE_ID + " AND " + Groups.GROUP_VISIBLE + "=1";
+
+        public static final String GROUP_HAS_ACCOUNT_AND_SOURCE_ID =
+                Groups.SOURCE_ID + "=? AND "
+                        + Groups.ACCOUNT_NAME + "=? AND "
+                        + Groups.ACCOUNT_TYPE + "=?";
+    }
+
+    public interface ContactsColumns {
+        public static final String OPTIMAL_PRIMARY_PHONE_ID = "optimal_phone_id";
+        public static final String OPTIMAL_PRIMARY_PHONE_IS_RESTRICTED =
+                "optimal_phone_is_restricted";
+        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_IS_RESTRICTED =
+                "optimal_email_is_restricted";
+        public static final String FALLBACK_PRIMARY_EMAIL_ID = "fallback_email_id";
+
+        public static final String SINGLE_IS_RESTRICTED = "single_is_restricted";
+
+        public static final String CONCRETE_ID = Tables.CONTACTS + "." + BaseColumns._ID;
+        public static final String CONCRETE_DISPLAY_NAME = Tables.CONTACTS + "."
+                + Contacts.DISPLAY_NAME;
+
+        public static final String CONCRETE_TIMES_CONTACTED = Tables.CONTACTS + "."
+                + Contacts.TIMES_CONTACTED;
+        public static final String CONCRETE_LAST_TIME_CONTACTED = Tables.CONTACTS + "."
+                + Contacts.LAST_TIME_CONTACTED;
+        public static final String CONCRETE_STARRED = Tables.CONTACTS + "." + Contacts.STARRED;
+        public static final String CONCRETE_CUSTOM_RINGTONE = Tables.CONTACTS + "."
+                + Contacts.CUSTOM_RINGTONE;
+        public static final String CONCRETE_SEND_TO_VOICEMAIL = Tables.CONTACTS + "."
+                + Contacts.SEND_TO_VOICEMAIL;
+    }
+
+    public interface RawContactsColumns {
+        public static final String CONCRETE_ID =
+                Tables.RAW_CONTACTS + "." + BaseColumns._ID;
+        public static final String CONCRETE_ACCOUNT_NAME =
+                Tables.RAW_CONTACTS + "." + RawContacts.ACCOUNT_NAME;
+        public static final String CONCRETE_ACCOUNT_TYPE =
+                Tables.RAW_CONTACTS + "." + RawContacts.ACCOUNT_TYPE;
+        public static final String CONCRETE_SOURCE_ID =
+                Tables.RAW_CONTACTS + "." + RawContacts.SOURCE_ID;
+        public static final String CONCRETE_VERSION =
+                Tables.RAW_CONTACTS + "." + RawContacts.VERSION;
+        public static final String CONCRETE_DIRTY =
+                Tables.RAW_CONTACTS + "." + RawContacts.DIRTY;
+        public static final String CONCRETE_DELETED =
+                Tables.RAW_CONTACTS + "." + RawContacts.DELETED;
+        public static final String DISPLAY_NAME = "display_name";
+    }
+
+    public interface DataColumns {
+        public static final String PACKAGE_ID = "package_id";
+        public static final String MIMETYPE_ID = "mimetype_id";
+
+        public static final String CONCRETE_ID = Tables.DATA + "." + BaseColumns._ID;
+        public static final String CONCRETE_MIMETYPE_ID = Tables.DATA + "." + MIMETYPE_ID;
+        public static final String CONCRETE_RAW_CONTACT_ID = Tables.DATA + "."
+                + Data.RAW_CONTACT_ID;
+        public static final String CONCRETE_GROUP_ID = Tables.DATA + "."
+                + GroupMembership.GROUP_ROW_ID;
+
+        public static final String CONCRETE_DATA1 = Tables.DATA + "." + Data.DATA1;
+        public static final String CONCRETE_DATA2 = Tables.DATA + "." + Data.DATA2;
+        public static final String CONCRETE_DATA3 = Tables.DATA + "." + Data.DATA3;
+        public static final String CONCRETE_DATA4 = Tables.DATA + "." + Data.DATA4;
+        public static final String CONCRETE_DATA5 = Tables.DATA + "." + Data.DATA5;
+        public static final String CONCRETE_DATA6 = Tables.DATA + "." + Data.DATA6;
+        public static final String CONCRETE_DATA7 = Tables.DATA + "." + Data.DATA7;
+        public static final String CONCRETE_DATA8 = Tables.DATA + "." + Data.DATA8;
+        public static final String CONCRETE_DATA9 = Tables.DATA + "." + Data.DATA9;
+        public static final String CONCRETE_DATA10 = Tables.DATA + "." + Data.DATA10;
+        public static final String CONCRETE_DATA11 = Tables.DATA + "." + Data.DATA11;
+        public static final String CONCRETE_DATA12 = Tables.DATA + "." + Data.DATA12;
+        public static final String CONCRETE_DATA13 = Tables.DATA + "." + Data.DATA13;
+        public static final String CONCRETE_DATA14 = Tables.DATA + "." + Data.DATA14;
+        public static final String CONCRETE_DATA15 = Tables.DATA + "." + Data.DATA15;
+        public static final String CONCRETE_IS_PRIMARY = Tables.DATA + "." + Data.IS_PRIMARY;
+        public static final String CONCRETE_PACKAGE_ID = Tables.DATA + "." + PACKAGE_ID;
+    }
+
+    // Used only for legacy API support
+    public interface ExtensionsColumns {
+        public static final String NAME = Data.DATA1;
+        public static final String VALUE = Data.DATA2;
+    }
+
+    public interface GroupMembershipColumns {
+        public static final String RAW_CONTACT_ID = Data.RAW_CONTACT_ID;
+        public static final String GROUP_ROW_ID = GroupMembership.GROUP_ROW_ID;
+    }
+
+    public interface PhoneColumns {
+        public static final String NORMALIZED_NUMBER = Data.DATA4;
+        public static final String CONCRETE_NORMALIZED_NUMBER = DataColumns.CONCRETE_DATA4;
+    }
+
+    public interface GroupsColumns {
+        public static final String PACKAGE_ID = "package_id";
+
+        public static final String CONCRETE_ID = Tables.GROUPS + "." + BaseColumns._ID;
+        public static final String CONCRETE_SOURCE_ID = Tables.GROUPS + "." + Groups.SOURCE_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 RAW_CONTACT_ID = "raw_contact_id";
+        public static final String NORMALIZED_NUMBER = "normalized_number";
+    }
+
+    public interface NameLookupColumns {
+        public static final String _ID = BaseColumns._ID;
+        public static final String RAW_CONTACT_ID = "raw_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 static final String CONCRETE_ID = Tables.PACKAGES + "." + _ID;
+    }
+
+    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 RAW_CONTACT_ID1 = "raw_contact_id1";
+        public static final String RAW_CONTACT_ID2 = "raw_contact_id2";
+    }
+
+    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 mContactIdQuery;
+    private SQLiteStatement mAggregationModeQuery;
+    private SQLiteStatement mContactIdUpdate;
+    private SQLiteStatement mMimetypeInsert;
+    private SQLiteStatement mPackageInsert;
+    private SQLiteStatement mNameLookupInsert;
+
+    private SQLiteStatement mDataMimetypeQuery;
+    private SQLiteStatement mActivitiesMimetypeQuery;
+
+    private final Context mContext;
+    private final SyncStateContentProviderHelper mSyncState;
+    private HashMap<String, String[]> mNicknameClusterCache;
+
+    /** Compiled statements for updating {@link Contacts#IN_VISIBLE_GROUP}. */
+    private SQLiteStatement mVisibleAllUpdate;
+    private SQLiteStatement mVisibleSpecificUpdate;
+
+    private Delegate mDelegate;
+
+    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(android.content.Context)} instead.
+     */
+    /* package */ OpenHelper(Context context) {
+        super(context, DATABASE_NAME, null, DATABASE_VERSION);
+        Log.i(TAG, "Creating OpenHelper");
+
+        mContext = context;
+        mSyncState = new SyncStateContentProviderHelper();
+    }
+
+    public Delegate getDelegate() {
+        return mDelegate;
+    }
+
+    public void setDelegate(Delegate delegate) {
+        mDelegate = delegate;
+    }
+
+    @Override
+    public void onOpen(SQLiteDatabase db) {
+        mSyncState.onDatabaseOpened(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 + "=?");
+        mContactIdQuery = db.compileStatement("SELECT " + RawContacts.CONTACT_ID + " FROM "
+                + Tables.RAW_CONTACTS + " WHERE " + RawContacts._ID + "=?");
+        mContactIdUpdate = db.compileStatement("UPDATE " + Tables.RAW_CONTACTS + " SET "
+                + RawContacts.CONTACT_ID + "=?" + " WHERE " + RawContacts._ID + "=?");
+        mAggregationModeQuery = db.compileStatement("SELECT " + RawContacts.AGGREGATION_MODE
+                + " FROM " + Tables.RAW_CONTACTS + " WHERE " + RawContacts._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.RAW_CONTACT_ID + "," + NameLookupColumns.NAME_TYPE + ","
+                + NameLookupColumns.NORMALIZED_NAME + ") VALUES (?,?,?)");
+
+        final String visibleUpdate = "UPDATE " + Tables.CONTACTS + " SET "
+                + Contacts.IN_VISIBLE_GROUP + "= (" + Clauses.IN_VISIBLE_GROUP + ")";
+
+        mVisibleAllUpdate = db.compileStatement(visibleUpdate);
+        mVisibleSpecificUpdate = db.compileStatement(visibleUpdate + " WHERE "
+                + ContactsColumns.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 + " ("+
+                Presence._ID + " INTEGER PRIMARY KEY," +
+                Presence.RAW_CONTACT_ID + " INTEGER REFERENCES raw_contacts(_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.RAW_CONTACT_ID + ");");
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        Log.i(TAG, "Bootstrapping database");
+
+        mSyncState.createDatabase(db);
+
+        // One row per group of contacts corresponding to the same person
+        db.execSQL("CREATE TABLE " + Tables.CONTACTS + " (" +
+                BaseColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                Contacts.DISPLAY_NAME + " TEXT," +
+                Contacts.PHOTO_ID + " INTEGER REFERENCES data(_id)," +
+                Contacts.CUSTOM_RINGTONE + " TEXT," +
+                Contacts.SEND_TO_VOICEMAIL + " INTEGER NOT NULL DEFAULT 0," +
+                Contacts.TIMES_CONTACTED + " INTEGER NOT NULL DEFAULT 0," +
+                Contacts.LAST_TIME_CONTACTED + " INTEGER," +
+                Contacts.STARRED + " INTEGER NOT NULL DEFAULT 0," +
+                Contacts.IN_VISIBLE_GROUP + " INTEGER NOT NULL DEFAULT 1," +
+                ContactsColumns.OPTIMAL_PRIMARY_PHONE_ID + " INTEGER REFERENCES data(_id)," +
+                ContactsColumns.OPTIMAL_PRIMARY_PHONE_IS_RESTRICTED + " INTEGER DEFAULT 0," +
+                ContactsColumns.FALLBACK_PRIMARY_PHONE_ID + " INTEGER REFERENCES data(_id)," +
+                ContactsColumns.OPTIMAL_PRIMARY_EMAIL_ID + " INTEGER REFERENCES data(_id)," +
+                ContactsColumns.OPTIMAL_PRIMARY_EMAIL_IS_RESTRICTED + " INTEGER DEFAULT 0," +
+                ContactsColumns.FALLBACK_PRIMARY_EMAIL_ID + " INTEGER REFERENCES data(_id)," +
+                ContactsColumns.SINGLE_IS_RESTRICTED + " INTEGER REFERENCES package(_id)" +
+        ");");
+
+        // Contacts table
+        db.execSQL("CREATE TABLE " + Tables.RAW_CONTACTS + " (" +
+                RawContacts._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                RawContacts.IS_RESTRICTED + " INTEGER DEFAULT 0," +
+                RawContacts.ACCOUNT_NAME + " STRING DEFAULT NULL, " +
+                RawContacts.ACCOUNT_TYPE + " STRING DEFAULT NULL, " +
+                RawContacts.SOURCE_ID + " TEXT," +
+                RawContacts.VERSION + " INTEGER NOT NULL DEFAULT 1," +
+                RawContacts.DIRTY + " INTEGER NOT NULL DEFAULT 1," +
+                RawContacts.DELETED + " INTEGER NOT NULL DEFAULT 0," +
+                RawContacts.CONTACT_ID + " INTEGER," +
+                RawContacts.AGGREGATION_MODE + " INTEGER NOT NULL DEFAULT " +
+                        RawContacts.AGGREGATION_MODE_DEFAULT + "," +
+                RawContacts.CUSTOM_RINGTONE + " TEXT," +
+                RawContacts.SEND_TO_VOICEMAIL + " INTEGER NOT NULL DEFAULT 0," +
+                RawContacts.TIMES_CONTACTED + " INTEGER NOT NULL DEFAULT 0," +
+                RawContacts.LAST_TIME_CONTACTED + " INTEGER," +
+                RawContacts.STARRED + " INTEGER NOT NULL DEFAULT 0," +
+                RawContactsColumns.DISPLAY_NAME + " TEXT" +
+        ");");
+
+        // 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.PACKAGE_ID + " INTEGER REFERENCES package(_id)," +
+                DataColumns.MIMETYPE_ID + " INTEGER REFERENCES mimetype(_id) NOT NULL," +
+                Data.RAW_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," +
+                Data.DATA11 + " TEXT," +
+                Data.DATA12 + " TEXT," +
+                Data.DATA13 + " TEXT," +
+                Data.DATA14 + " TEXT," +
+                Data.DATA15 + " TEXT" +
+        ");");
+
+        /**
+         * Automatically delete Data rows when a raw contact is deleted.
+         */
+        db.execSQL("CREATE TRIGGER " + Tables.RAW_CONTACTS + "_deleted "
+                + "   BEFORE DELETE ON " + Tables.RAW_CONTACTS
+                + " BEGIN "
+                + "   DELETE FROM " + Tables.DATA
+                + "     WHERE " + Data.RAW_CONTACT_ID + "=OLD." + RawContacts._ID + ";"
+                + "   DELETE FROM " + Tables.PHONE_LOOKUP
+                + "     WHERE " + PhoneLookupColumns.RAW_CONTACT_ID + "=OLD." + RawContacts._ID + ";"
+                + " END");
+
+        /**
+         * Triggers that set {@link RawContacts#DIRTY} and update {@link RawContacts#VERSION}
+         * when the contact is marked for deletion or any time a data row is inserted, updated
+         * or deleted.
+         */
+        db.execSQL("CREATE TRIGGER " + Tables.RAW_CONTACTS + "_marked_deleted "
+                + "   BEFORE UPDATE ON " + Tables.RAW_CONTACTS
+                + " BEGIN "
+                + "   UPDATE " + Tables.RAW_CONTACTS
+                + "     SET "
+                +         RawContacts.VERSION + "=OLD." + RawContacts.VERSION + "+1, "
+                +         RawContacts.DIRTY + "=1"
+                + "     WHERE " + RawContacts._ID + "=OLD." + RawContacts._ID
+                + "       AND NEW." + RawContacts.DELETED + "!= OLD." + RawContacts.DELETED + ";"
+                + " 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.RAW_CONTACTS
+                + "     SET " + RawContacts.DIRTY + "=1, "
+                + "         " +	RawContacts.VERSION + "=" + RawContacts.VERSION + "+1 "
+                + "     WHERE " + RawContacts._ID + "=OLD." + Data.RAW_CONTACT_ID + ";"
+                + " END");
+
+        db.execSQL("CREATE TRIGGER " + Tables.DATA + "_inserted BEFORE INSERT ON " + Tables.DATA
+                + " BEGIN "
+                + "   UPDATE " + Tables.RAW_CONTACTS
+                + "     SET " + RawContacts.DIRTY + "=1, "
+                + "         " + RawContacts.VERSION + "=" + RawContacts.VERSION + "+1 "
+                + "     WHERE " + RawContacts._ID + "=NEW." + Data.RAW_CONTACT_ID + ";"
+                + " END");
+
+        db.execSQL("CREATE TRIGGER " + Tables.DATA + "_deleted BEFORE DELETE ON " + Tables.DATA
+                + " BEGIN "
+                + "   UPDATE " + Tables.RAW_CONTACTS
+                + "     SET " + RawContacts.DIRTY + "=1,"
+                + "         " + RawContacts.VERSION + "=" + RawContacts.VERSION + "+1 "
+                + "     WHERE " + RawContacts._ID + "=OLD." + Data.RAW_CONTACT_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.RAW_CONTACT_ID
+                        + " INTEGER REFERENCES raw_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.RAW_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.RAW_CONTACT_ID
+                        + " INTEGER REFERENCES raw_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.RAW_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," +
+                GroupsColumns.PACKAGE_ID + " INTEGER REFERENCES package(_id)," +
+                Groups.ACCOUNT_NAME + " STRING DEFAULT NULL, " +
+                Groups.ACCOUNT_TYPE + " STRING DEFAULT NULL, " +
+                Groups.SOURCE_ID + " TEXT," +
+                Groups.VERSION + " INTEGER NOT NULL DEFAULT 1," +
+                Groups.DIRTY + " INTEGER NOT NULL DEFAULT 1," +
+                Groups.TITLE + " TEXT," +
+                Groups.TITLE_RES + " INTEGER," +
+                Groups.NOTES + " TEXT," +
+                Groups.SYSTEM_ID + " TEXT," +
+                Groups.GROUP_VISIBLE + " INTEGER" +
+        ");");
+
+        db.execSQL("CREATE TRIGGER " + Tables.GROUPS + "_updated1 "
+                + "   BEFORE UPDATE ON " + Tables.GROUPS
+                + " BEGIN "
+                + "   UPDATE " + Tables.GROUPS
+                + "     SET "
+                +         Groups.VERSION + "=OLD." + Groups.VERSION + "+1, "
+                +         Groups.DIRTY + "=1"
+                + "     WHERE " + Groups._ID + "=OLD." + Groups._ID + ";"
+                + " END");
+
+        db.execSQL("CREATE TABLE IF NOT EXISTS " + Tables.AGGREGATION_EXCEPTIONS + " (" +
+                AggregationExceptionColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                AggregationExceptions.TYPE + " INTEGER NOT NULL, " +
+                AggregationExceptionColumns.RAW_CONTACT_ID1
+                        + " INTEGER REFERENCES raw_contacts(_id), " +
+                AggregationExceptionColumns.RAW_CONTACT_ID2
+                        + " INTEGER REFERENCES raw_contacts(_id)" +
+        ");");
+
+        db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS aggregation_exception_index1 ON " +
+                Tables.AGGREGATION_EXCEPTIONS + " (" +
+                AggregationExceptionColumns.RAW_CONTACT_ID1 + ", " +
+                AggregationExceptionColumns.RAW_CONTACT_ID2 +
+        ");");
+
+        db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS aggregation_exception_index2 ON " +
+                Tables.AGGREGATION_EXCEPTIONS + " (" +
+                AggregationExceptionColumns.RAW_CONTACT_ID2 + ", " +
+                AggregationExceptionColumns.RAW_CONTACT_ID1 +
+        ");");
+
+        // The table for recent calls is here so we can do table joins
+        // on people, phones, and calls all in one place.
+        db.execSQL("CREATE TABLE " + Tables.CALLS + " (" +
+                Calls._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                Calls.NUMBER + " TEXT," +
+                Calls.DATE + " INTEGER," +
+                Calls.DURATION + " INTEGER," +
+                Calls.TYPE + " INTEGER," +
+                Calls.NEW + " INTEGER," +
+                Calls.CACHED_NAME + " TEXT," +
+                Calls.CACHED_NUMBER_TYPE + " INTEGER," +
+                Calls.CACHED_NUMBER_LABEL + " TEXT" +
+        ");");
+
+        // Activities table
+        db.execSQL("CREATE TABLE " + Tables.ACTIVITIES + " (" +
+                Activities._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                ActivitiesColumns.PACKAGE_ID + " INTEGER REFERENCES package(_id)," +
+                ActivitiesColumns.MIMETYPE_ID + " INTEGER REFERENCES mimetype(_id) NOT NULL," +
+                Activities.RAW_ID + " TEXT," +
+                Activities.IN_REPLY_TO + " TEXT," +
+                Activities.AUTHOR_CONTACT_ID +  " INTEGER REFERENCES raw_contacts(_id)," +
+                Activities.TARGET_CONTACT_ID + " INTEGER REFERENCES raw_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" +
+        ");");
+
+        db.execSQL("CREATE VIEW " + Tables.CONTACT_ENTITIES + " AS SELECT "
+                + RawContactsColumns.CONCRETE_ACCOUNT_NAME + " AS " + RawContacts.ACCOUNT_NAME + ","
+                + RawContactsColumns.CONCRETE_ACCOUNT_TYPE + " AS " + RawContacts.ACCOUNT_TYPE + ","
+                + RawContactsColumns.CONCRETE_SOURCE_ID + " AS " + RawContacts.SOURCE_ID + ","
+                + RawContactsColumns.CONCRETE_VERSION + " AS " + RawContacts.VERSION + ","
+                + RawContactsColumns.CONCRETE_DIRTY + " AS " + RawContacts.DIRTY + ","
+                + PackagesColumns.PACKAGE + " AS " + Data.RES_PACKAGE + ","
+                + RawContacts.CONTACT_ID + ", "
+                + Data.MIMETYPE + ", "
+                + Data.DATA1 + ", "
+                + Data.DATA2 + ", "
+                + Data.DATA3 + ", "
+                + Data.DATA4 + ", "
+                + Data.DATA5 + ", "
+                + Data.DATA6 + ", "
+                + Data.DATA7 + ", "
+                + Data.DATA8 + ", "
+                + Data.DATA9 + ", "
+                + Data.DATA10 + ", "
+                + Data.DATA11 + ", "
+                + Data.DATA12 + ", "
+                + Data.DATA13 + ", "
+                + Data.DATA14 + ", "
+                + Data.DATA15 + ", "
+                + Data.RAW_CONTACT_ID + ", "
+                + Data.IS_PRIMARY + ", "
+                + Data.DATA_VERSION + ", "
+                + DataColumns.CONCRETE_ID + " AS " + RawContacts._ID + ","
+                + Tables.GROUPS + "." + Groups.SOURCE_ID + " AS " + GroupMembership.GROUP_SOURCE_ID
+                + " FROM " + Tables.DATA
+                + " LEFT OUTER JOIN " + Tables.PACKAGES + " ON ("
+                +   DataColumns.CONCRETE_PACKAGE_ID + "=" + PackagesColumns.CONCRETE_ID + ")"
+                + " LEFT OUTER JOIN " + Tables.MIMETYPES + " ON ("
+                +   DataColumns.CONCRETE_MIMETYPE_ID + "=" + MimetypesColumns.CONCRETE_ID + ")"
+                + " LEFT OUTER JOIN " + Tables.RAW_CONTACTS + " ON ("
+                +   DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID + ")"
+                + " LEFT OUTER JOIN " + Tables.GROUPS + " ON ("
+                +   MimetypesColumns.CONCRETE_MIMETYPE + "='" + GroupMembership.CONTENT_ITEM_TYPE
+                +   "' AND " + GroupsColumns.CONCRETE_ID + "=" + DataColumns.CONCRETE_DATA1 + ")");
+
+        loadNicknameLookupTable(db);
+        if (mDelegate != null) {
+            mDelegate.createDatabase(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.CONTACTS + ";");
+        db.execSQL("DROP TABLE IF EXISTS " + Tables.RAW_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.ACTIVITIES + ";");
+        db.execSQL("DROP TABLE IF EXISTS " + Tables.CALLS);
+
+        db.execSQL("DROP VIEW IF EXISTS " + Tables.CONTACT_ENTITIES + ";");
+
+        // 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 + ";");
+
+        onCreate(db);
+
+        // TODO: eventually when this supports upgrades we should do something like the following:
+//        if (!upgradeDatabase(db, oldVersion, newVersion)) {
+//            mSyncState.discardSyncData(db, null /* all accounts */);
+//            ContentResolver.requestSync(null /* all accounts */,
+//                    mContentUri.getAuthority(), new Bundle());
+//        }
+    }
+
+    /**
+     * Wipes all data except mime type and package lookup tables.
+     */
+    public void wipeData() {
+        SQLiteDatabase db = getWritableDatabase();
+        db.execSQL("DELETE FROM " + Tables.CONTACTS + ";");
+        db.execSQL("DELETE FROM " + Tables.RAW_CONTACTS + ";");
+        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.ACTIVITIES + ";");
+        db.execSQL("DELETE FROM " + Tables.CALLS);
+
+        // 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 Contacts#IN_VISIBLE_GROUP} for all contacts.
+     */
+    public void updateAllVisible() {
+        final long groupMembershipMimetypeId = getMimeTypeId(GroupMembership.CONTENT_ITEM_TYPE);
+        mVisibleAllUpdate.bindLong(1, groupMembershipMimetypeId);
+        mVisibleAllUpdate.execute();
+    }
+
+    /**
+     * Update {@link Contacts#IN_VISIBLE_GROUP} for a specific contact.
+     */
+    public void updateContactVisible(long aggId) {
+        final long groupMembershipMimetypeId = getMimeTypeId(GroupMembership.CONTENT_ITEM_TYPE);
+        mVisibleSpecificUpdate.bindLong(1, groupMembershipMimetypeId);
+        mVisibleSpecificUpdate.bindLong(2, aggId);
+        mVisibleSpecificUpdate.execute();
+    }
+
+    /**
+     * Updates the contact ID for the specified contact.
+     */
+    public void setContactId(long rawContactId, long contactId) {
+        getWritableDatabase();
+        DatabaseUtils.bindObjectToProgram(mContactIdUpdate, 1, contactId);
+        DatabaseUtils.bindObjectToProgram(mContactIdUpdate, 2, rawContactId);
+        mContactIdUpdate.execute();
+    }
+
+    /**
+     * Returns contact ID for the given contact or zero if it is NULL.
+     */
+    public long getContactId(long rawContactId) {
+        getReadableDatabase();
+        try {
+            DatabaseUtils.bindObjectToProgram(mContactIdQuery, 1, rawContactId);
+            return mContactIdQuery.simpleQueryForLong();
+        } catch (SQLiteDoneException e) {
+            // No valid mapping found, so return -1
+            return 0;
+        }
+    }
+
+    public int getAggregationMode(long rawContactId) {
+        getReadableDatabase();
+        try {
+            DatabaseUtils.bindObjectToProgram(mAggregationModeQuery, 1, rawContactId);
+            return (int)mAggregationModeQuery.simpleQueryForLong();
+        } catch (SQLiteDoneException e) {
+            // No valid row found, so return "disabled"
+            return RawContacts.AGGREGATION_MODE_DISABLED;
+        }
+    }
+
+    /**
+     * Inserts a record in the {@link Tables#NAME_LOOKUP} table.
+     */
+    public void insertNameLookup(long rawContactId, int lookupType, String name) {
+        getWritableDatabase();
+        DatabaseUtils.bindObjectToProgram(mNameLookupInsert, 1, rawContactId);
+        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(Tables.RAW_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.raw_contact_id=raw_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;
+    }
+
+    public static void copyStringValue(ContentValues toValues, String toKey,
+            ContentValues fromValues, String fromKey) {
+        if (fromValues.containsKey(fromKey)) {
+            toValues.put(toKey, fromValues.getAsString(fromKey));
+        }
+    }
+
+    public static void copyLongValue(ContentValues toValues, String toKey,
+            ContentValues fromValues, String fromKey) {
+        if (fromValues.containsKey(fromKey)) {
+            long longValue;
+            Object value = fromValues.get(fromKey);
+            if (value instanceof Boolean) {
+                if ((Boolean)value) {
+                    longValue = 1;
+                } else {
+                    longValue = 0;
+                }
+            } else {
+                longValue = ((Number) value).longValue();
+            }
+            toValues.put(toKey, longValue);
+        }
+    }
+
+    public SyncStateContentProviderHelper getSyncState() {
+        return mSyncState;
+    }
+
+    /**
+     * Delete the aggregate contact if it has no constituent raw contacts other
+     * than the supplied one.
+     */
+    public void removeContactIfSingleton(long rawContactId) {
+        SQLiteDatabase db = getWritableDatabase();
+
+        // Obtain contact ID from the supplied raw contact ID
+        String contactIdFromRawContactId = "(SELECT " + RawContacts.CONTACT_ID + " FROM "
+                + Tables.RAW_CONTACTS + " WHERE " + RawContacts._ID + "=" + rawContactId + ")";
+
+        // Find other raw contacts in the same aggregate contact
+        String otherRawContacts = "(SELECT contacts1." + RawContacts._ID + " FROM "
+                + Tables.RAW_CONTACTS + " contacts1 JOIN " + Tables.RAW_CONTACTS + " contacts2 ON ("
+                + "contacts1." + RawContacts.CONTACT_ID + "=contacts2." + RawContacts.CONTACT_ID
+                + ") WHERE contacts1." + RawContacts._ID + "!=" + rawContactId + ""
+                + " AND contacts2." + RawContacts._ID + "=" + rawContactId + ")";
+
+        db.execSQL("DELETE FROM " + Tables.CONTACTS
+                + " WHERE " + Contacts._ID + "=" + contactIdFromRawContactId
+                + " AND NOT EXISTS " + otherRawContacts + ";");
+    }
+}
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..a5ea0c9
--- /dev/null
+++ b/src/com/android/providers/contacts/SocialProvider.java
@@ -0,0 +1,404 @@
+/*
+ * 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.PackagesColumns;
+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.Contacts;
+import android.provider.ContactsContract.RawContacts;
+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 CONTACT_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> sContactsProjectionMap;
+    /** Contains just the contacts columns */
+    private static final HashMap<String, String> sRawContactsProjectionMap;
+    /** Contains just the activities columns */
+    private static final HashMap<String, String> sActivitiesProjectionMap;
+
+    /** Contains the activities, raw contacts, and contacts columns, for joined tables */
+    private static final HashMap<String, String> sActivitiesContactsProjectionMap;
+
+    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, "contact_status/#", CONTACT_STATUS_ID);
+
+        HashMap<String, String> columns;
+
+        // Contacts projection map
+        columns = new HashMap<String, String>();
+        columns.put(Contacts.DISPLAY_NAME, Contacts.DISPLAY_NAME);
+        sContactsProjectionMap = columns;
+
+        // Contacts projection map
+        columns = new HashMap<String, String>();
+        columns.put(RawContacts._ID, Tables.RAW_CONTACTS + "." + RawContacts._ID + " AS _id");
+        columns.put(RawContacts.CONTACT_ID, RawContacts.CONTACT_ID);
+        sRawContactsProjectionMap = columns;
+
+        // Activities projection map
+        columns = new HashMap<String, String>();
+        columns.put(Activities._ID, "activities._id AS _id");
+        columns.put(Activities.RES_PACKAGE, PackagesColumns.PACKAGE + " AS "
+                + Activities.RES_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, raw contacts, and contacts projection map for joins
+        columns = new HashMap<String, String>();
+        columns.putAll(sContactsProjectionMap);
+        columns.putAll(sRawContactsProjectionMap);
+        columns.putAll(sActivitiesProjectionMap); // Final _id will be from Activities
+        sActivitiesContactsProjectionMap = 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.RES_PACKAGE);
+            if (packageName != null) {
+                values.put(ActivitiesColumns.PACKAGE_ID, mOpenHelper.getPackageId(packageName));
+            }
+            values.remove(Activities.RES_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_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
+                qb.setProjectionMap(sActivitiesContactsProjectionMap);
+                break;
+            }
+
+            case ACTIVITIES_ID: {
+                // TODO: enforce that caller has read access to this data
+                long activityId = ContentUris.parseId(uri);
+                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
+                qb.setProjectionMap(sActivitiesContactsProjectionMap);
+                qb.appendWhere(Activities._ID + "=" + activityId);
+                break;
+            }
+
+            case ACTIVITIES_AUTHORED_BY: {
+                long contactId = ContentUris.parseId(uri);
+                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
+                qb.setProjectionMap(sActivitiesContactsProjectionMap);
+                qb.appendWhere(Activities.AUTHOR_CONTACT_ID + "=" + contactId);
+                break;
+            }
+
+            case CONTACT_STATUS_ID: {
+                long aggId = ContentUris.parseId(uri);
+                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
+                qb.setProjectionMap(sActivitiesContactsProjectionMap);
+
+                // Latest status of a contact 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.RAW_CONTACTS + " WHERE " + RawContacts.CONTACT_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 CONTACT_STATUS_ID:
+                return Contacts.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..ec48f5a
--- /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 := ContactsProviderTests
+
+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..5063a7d
--- /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="Contacts 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..cc27428
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/BaseContactsProvider2Test.java
@@ -0,0 +1,600 @@
+/*
+ * 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.accounts.Account;
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Entity;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Groups;
+import android.provider.ContactsContract.Presence;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.test.AndroidTestCase;
+import android.test.mock.MockContentResolver;
+import android.test.suitebuilder.annotation.LargeTest;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A common superclass for {@link ContactsProvider2}-related tests.
+ */
+@LargeTest
+public abstract class BaseContactsProvider2Test extends AndroidTestCase {
+
+    protected static final String PACKAGE = "ContactsProvider2Test";
+
+    protected ContactsActor mActor;
+    protected MockContentResolver mResolver;
+    protected Account mAccount = new Account("account1", "account type1");
+
+    protected final static Long NO_LONG = new Long(0);
+    protected final static String NO_STRING = new String("");
+    protected final static Account NO_ACCOUNT = new Account("a", "b");
+
+    protected Class<? extends ContentProvider> getProviderClass() {
+        return SynchronousContactsProvider2.class;
+    }
+
+    protected String getAuthority() {
+        return ContactsContract.AUTHORITY;
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mActor = new ContactsActor(getContext(), PACKAGE_GREY, getProviderClass(), getAuthority());
+        mResolver = mActor.resolver;
+        if (mActor.provider instanceof SynchronousContactsProvider2) {
+            ((SynchronousContactsProvider2) mActor.provider)
+                    .getOpenHelper(mActor.context).wipeData();
+        }
+    }
+
+    public Context getMockContext() {
+        return mActor.context;
+    }
+
+    public void addAuthority(String authority) {
+        mActor.addAuthority(authority);
+    }
+
+    public ContentProvider addProvider(Class<? extends ContentProvider> providerClass,
+            String authority) throws Exception {
+        return mActor.addProvider(providerClass, authority);
+    }
+
+    protected Uri maybeAddAccountQueryParameters(Uri uri, Account account) {
+        if (account == null) {
+            return uri;
+        }
+        return uri.buildUpon()
+                .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.mName)
+                .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.mType)
+                .build();
+    }
+
+    protected long createRawContact() {
+        return createRawContact(null);
+    }
+
+    protected long createRawContactWithName() {
+        long rawContactId = createRawContact(null);
+        insertStructuredName(rawContactId, "John", "Doe");
+        return rawContactId;
+    }
+
+    protected long createRawContact(Account account, String... extras) {
+        ContentValues values = new ContentValues();
+        for (int i = 0; i < extras.length; ) {
+            values.put(extras[i], extras[i + 1]);
+            i += 2;
+        }
+        final Uri uri = maybeAddAccountQueryParameters(RawContacts.CONTENT_URI, account);
+        Uri contactUri = mResolver.insert(uri, values);
+        return ContentUris.parseId(contactUri);
+    }
+
+    protected long createGroup(Account account, String sourceId, String title) {
+        ContentValues values = new ContentValues();
+        values.put(Groups.SOURCE_ID, sourceId);
+        values.put(Groups.TITLE, title);
+        values.put(Groups.GROUP_VISIBLE, 1);
+        final Uri uri = maybeAddAccountQueryParameters(Groups.CONTENT_URI, account);
+        return ContentUris.parseId(mResolver.insert(uri, values));
+    }
+
+    protected Uri insertStructuredName(long rawContactId, 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(rawContactId, values);
+    }
+
+    protected Uri insertStructuredName(long rawContactId, ContentValues values) {
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertPhoneNumber(long rawContactId, String phoneNumber) {
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+        values.put(Phone.NUMBER, phoneNumber);
+        values.put(Phone.TYPE, Phone.TYPE_HOME);
+
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertEmail(long rawContactId, String email) {
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+        values.put(Email.DATA, email);
+        values.put(Email.TYPE, Email.TYPE_HOME);
+
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertNickname(long rawContactId, String nickname) {
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE);
+        values.put(Nickname.NAME, nickname);
+        values.put(Nickname.TYPE, Nickname.TYPE_OTHER_NAME);
+
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertPhoto(long rawContactId) {
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE);
+
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertGroupMembership(long rawContactId, String sourceId) {
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
+        values.put(GroupMembership.GROUP_SOURCE_ID, sourceId);
+        return mResolver.insert(Data.CONTENT_URI, values);
+    }
+
+    protected Uri insertGroupMembership(long rawContactId, Long groupId) {
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
+        values.put(GroupMembership.GROUP_ROW_ID, groupId);
+        return mResolver.insert(Data.CONTENT_URI, values);
+    }
+
+    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 rawContactId, int protocol, String handle) {
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+        values.put(Im.PROTOCOL, protocol);
+        values.put(Im.DATA, handle);
+        values.put(Im.TYPE, Im.TYPE_HOME);
+
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected void setContactAccountName(long rawContactId, String accountName) {
+        ContentValues values = new ContentValues();
+        values.put(RawContacts.ACCOUNT_NAME, accountName);
+
+        mResolver.update(ContentUris.withAppendedId(
+                RawContacts.CONTENT_URI, rawContactId), values, null, null);
+    }
+
+    protected void setAggregationException(int type, long contactId, long rawContactId) {
+        ContentValues values = new ContentValues();
+        values.put(AggregationExceptions.CONTACT_ID, contactId);
+        values.put(AggregationExceptions.RAW_CONTACT_ID, rawContactId);
+        values.put(AggregationExceptions.TYPE, type);
+        mResolver.update(AggregationExceptions.CONTENT_URI, values, null, null);
+    }
+
+    protected Cursor queryRawContact(long rawContactId) {
+        return mResolver.query(ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId), null,
+                null, null, null);
+    }
+
+    protected Cursor queryContact(long contactId) {
+        return mResolver.query(ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                null, null, null, null);
+    }
+
+    protected Cursor queryContactSummary(long contactId, String[] projection) {
+        return mResolver.query(ContentUris.withAppendedId(Contacts.CONTENT_SUMMARY_URI,
+                contactId), projection, null, null, null);
+    }
+
+    protected Cursor queryContactSummary() {
+        return mResolver.query(Contacts.CONTENT_SUMMARY_URI, null, null, null, null);
+    }
+
+    protected long queryContactId(long rawContactId) {
+        Cursor c = queryRawContact(rawContactId);
+        assertTrue(c.moveToFirst());
+        long contactId = c.getLong(c.getColumnIndex(RawContacts.CONTACT_ID));
+        c.close();
+        return contactId;
+    }
+
+    protected long queryPhotoId(long contactId) {
+        Cursor c = queryContact(contactId);
+        assertTrue(c.moveToFirst());
+        long photoId = c.getInt(c.getColumnIndex(Contacts.PHOTO_ID));
+        c.close();
+        return photoId;
+    }
+
+    protected String queryDisplayName(long contactId) {
+        Cursor c = queryContact(contactId);
+        assertTrue(c.moveToFirst());
+        String displayName = c.getString(c.getColumnIndex(Contacts.DISPLAY_NAME));
+        c.close();
+        return displayName;
+    }
+
+    protected void assertAggregated(long rawContactId1, long rawContactId2) {
+        long contactId1 = queryContactId(rawContactId1);
+        long contactId2 = queryContactId(rawContactId2);
+        assertTrue(contactId1 == contactId2);
+    }
+
+    protected void assertAggregated(long rawContactId1, long rawContactId2,
+            String expectedDisplayName) {
+        long contactId1 = queryContactId(rawContactId1);
+        long contactId2 = queryContactId(rawContactId2);
+        assertTrue(contactId1 == contactId2);
+
+        String displayName = queryDisplayName(contactId1);
+        assertEquals(expectedDisplayName, displayName);
+    }
+
+    protected void assertNotAggregated(long rawContactId1, long rawContactId2) {
+        long contactId1 = queryContactId(rawContactId1);
+        long contactId2 = queryContactId(rawContactId2);
+        assertTrue(contactId1 != contactId2);
+    }
+
+    protected void assertStructuredName(long rawContactId, String prefix, String givenName,
+            String middleName, String familyName, String suffix) {
+        Uri uri =
+                Uri.withAppendedPath(ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId),
+                RawContacts.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();
+    }
+
+    protected long assertSingleGroup(Long rowId, Account account, String sourceId, String title) {
+        Cursor c = mResolver.query(Groups.CONTENT_URI, null, null, null, null);
+        try {
+            assertTrue(c.moveToNext());
+            long actualRowId = assertGroup(c, rowId, account, sourceId, title);
+            assertFalse(c.moveToNext());
+            return actualRowId;
+        } finally {
+            c.close();
+        }
+    }
+
+    protected long assertSingleGroupMembership(Long rowId, Long rawContactId, Long groupRowId,
+            String sourceId) {
+        Cursor c = mResolver.query(ContactsContract.Data.CONTENT_URI, null, null, null, null);
+        try {
+            assertTrue(c.moveToNext());
+            long actualRowId = assertGroupMembership(c, rowId, rawContactId, groupRowId, sourceId);
+            assertFalse(c.moveToNext());
+            return actualRowId;
+        } finally {
+            c.close();
+        }
+    }
+
+    protected long assertGroupMembership(Cursor c, Long rowId, Long rawContactId, Long groupRowId,
+            String sourceId) {
+        assertNullOrEquals(c, rowId, Data._ID);
+        assertNullOrEquals(c, rawContactId, GroupMembership.RAW_CONTACT_ID);
+        assertNullOrEquals(c, groupRowId, GroupMembership.GROUP_ROW_ID);
+        assertNullOrEquals(c, sourceId, GroupMembership.GROUP_SOURCE_ID);
+        return c.getLong(c.getColumnIndexOrThrow("_id"));
+    }
+
+    protected long assertGroup(Cursor c, Long rowId, Account account, String sourceId, String title) {
+        assertNullOrEquals(c, rowId, Groups._ID);
+        assertNullOrEquals(c, account);
+        assertNullOrEquals(c, sourceId, Groups.SOURCE_ID);
+        assertNullOrEquals(c, title, Groups.TITLE);
+        return c.getLong(c.getColumnIndexOrThrow("_id"));
+    }
+
+    private void assertNullOrEquals(Cursor c, Account account) {
+        if (account == NO_ACCOUNT) {
+            return;
+        }
+        if (account == null) {
+            assertTrue(c.isNull(c.getColumnIndexOrThrow(Groups.ACCOUNT_NAME)));
+            assertTrue(c.isNull(c.getColumnIndexOrThrow(Groups.ACCOUNT_TYPE)));
+        } else {
+            assertEquals(account.mName, c.getString(c.getColumnIndexOrThrow(Groups.ACCOUNT_NAME)));
+            assertEquals(account.mType, c.getString(c.getColumnIndexOrThrow(Groups.ACCOUNT_TYPE)));
+        }
+    }
+
+    private void assertNullOrEquals(Cursor c, Long value, String columnName) {
+        if (value != NO_LONG) {
+            if (value == null) assertTrue(c.isNull(c.getColumnIndexOrThrow(columnName)));
+            else assertEquals((long) value, c.getLong(c.getColumnIndexOrThrow(columnName)));
+        }
+    }
+
+    private void assertNullOrEquals(Cursor c, String value, String columnName) {
+        if (value != NO_STRING) {
+            if (value == null) assertTrue(c.isNull(c.getColumnIndexOrThrow(columnName)));
+            else assertEquals(value, c.getString(c.getColumnIndexOrThrow(columnName)));
+        }
+    }
+
+    protected void assertDataRow(ContentValues actual, String expectedMimetype,
+            Object... expectedArguments) {
+        assertEquals(actual.toString(), expectedMimetype, actual.getAsString(Data.MIMETYPE));
+        for (int i = 0; i < expectedArguments.length; i += 2) {
+            String columnName = (String) expectedArguments[i];
+            Object expectedValue = expectedArguments[i + 1];
+            if (expectedValue instanceof Uri) {
+                expectedValue = ContentUris.parseId((Uri) expectedValue);
+            }
+            if (expectedValue == null) {
+                assertNull(actual.toString(), actual.get(columnName));
+            }
+            if (expectedValue instanceof Long) {
+                assertEquals("mismatch at " + columnName + " from " + actual.toString(),
+                        expectedValue, actual.getAsLong(columnName));
+            } else if (expectedValue instanceof Integer) {
+                assertEquals("mismatch at " + columnName + " from " + actual.toString(),
+                        expectedValue, actual.getAsInteger(columnName));
+            } else if (expectedValue instanceof String) {
+                assertEquals("mismatch at " + columnName + " from " + actual.toString(),
+                        expectedValue, actual.getAsString(columnName));
+            } else {
+                assertEquals("mismatch at " + columnName + " from " + actual.toString(),
+                        expectedValue, actual.get(columnName));
+            }
+        }
+    }
+
+    protected static class IdComparator implements Comparator<ContentValues> {
+        public int compare(ContentValues o1, ContentValues o2) {
+            long id1 = o1.getAsLong(ContactsContract.Data._ID);
+            long id2 = o2.getAsLong(ContactsContract.Data._ID);
+            if (id1 == id2) return 0;
+            return (id1 < id2) ? -1 : 1;
+        }
+    }
+
+    protected ContentValues[] asSortedContentValuesArray(
+            ArrayList<Entity.NamedContentValues> subValues) {
+        ContentValues[] result = new ContentValues[subValues.size()];
+        int i = 0;
+        for (Entity.NamedContentValues subValue : subValues) {
+            result[i] = subValue.values;
+            i++;
+        }
+        Arrays.sort(result, new IdComparator());
+        return result;
+    }
+
+    protected void assertDirty(Uri uri, boolean state) {
+        Cursor c = mResolver.query(uri, new String[]{"dirty"}, null, null, null);
+        assertTrue(c.moveToNext());
+        assertEquals(state, c.getLong(0) != 0);
+        assertFalse(c.moveToNext());
+    }
+
+    protected long getVersion(Uri uri) {
+        Cursor c = mResolver.query(uri, new String[]{"version"}, null, null, null);
+        assertTrue(c.moveToNext());
+        long version = c.getLong(0);
+        assertFalse(c.moveToNext());
+        return version;
+    }
+
+    protected void clearDirty(Uri uri) {
+        ContentValues values = new ContentValues();
+        values.put("dirty", 0);
+        mResolver.update(uri, values, null, null);
+    }
+
+    protected void assertStoredValues(Uri rowUri, String column, String expectedValue) {
+        String value = getStoredValue(rowUri, column);
+        assertEquals("Column value " + column, expectedValue, value);
+    }
+
+    protected String getStoredValue(Uri rowUri, String column) {
+        String value;
+        Cursor c = mResolver.query(rowUri, new String[] { column }, null, null, null);
+        try {
+            c.moveToFirst();
+            value = c.getString(c.getColumnIndex(column));
+        } finally {
+            c.close();
+        }
+        return value;
+    }
+
+    protected void assertStoredValues(Uri rowUri, ContentValues expectedValues) {
+        Cursor c = mResolver.query(rowUri, null, null, null, null);
+        try {
+            assertEquals("Record count", 1, c.getCount());
+            c.moveToFirst();
+            assertCursorValues(c, expectedValues);
+        } finally {
+            c.close();
+        }
+    }
+
+    /**
+     * Constructs a selection (where clause) out of all supplied values, uses it
+     * to query the provider and verifies that a single row is returned and it
+     * has the same values as requested.
+     */
+    protected void assertSelection(Uri uri, ContentValues values, String idColumn, long id) {
+        StringBuilder sb = new StringBuilder();
+        ArrayList<String> selectionArgs = new ArrayList<String>(values.size());
+        if (idColumn != null) {
+            sb.append(idColumn).append("=").append(id);
+        }
+        Set<Map.Entry<String, Object>> entries = values.valueSet();
+        for (Map.Entry<String, Object> entry : entries) {
+            String column = entry.getKey();
+            Object value = entry.getValue();
+            if (sb.length() != 0) {
+                sb.append(" AND ");
+            }
+            sb.append(column);
+            if (value == null) {
+                sb.append(" IS NULL");
+            } else {
+                sb.append("=?");
+                selectionArgs.add(String.valueOf(value));
+            }
+        }
+
+        Cursor c = mResolver.query(uri, null, sb.toString(), selectionArgs.toArray(new String[0]),
+                null);
+        try {
+            assertEquals("Record count", 1, c.getCount());
+            c.moveToFirst();
+            assertCursorValues(c, values);
+        } finally {
+            c.close();
+        }
+    }
+
+    protected void assertCursorValues(Cursor cursor, ContentValues expectedValues) {
+        Set<Map.Entry<String, Object>> entries = expectedValues.valueSet();
+        for (Map.Entry<String, Object> entry : entries) {
+            String column = entry.getKey();
+            int index = cursor.getColumnIndex(column);
+            assertTrue("No such column: " + column, index != -1);
+            Object expectedValue = expectedValues.get(column);
+            String value;
+            if (expectedValue instanceof byte[]) {
+                expectedValue = Hex.encodeHex((byte[])expectedValue, false);
+                value = Hex.encodeHex(cursor.getBlob(index), false);
+            } else {
+                expectedValue = expectedValues.getAsString(column);
+                value = cursor.getString(index);
+            }
+            assertEquals("Column value " + column, expectedValue, value);
+        }
+    }
+
+    protected int getCount(Uri uri, String selection, String[] selectionArgs) {
+        Cursor c = mResolver.query(uri, null, selection, selectionArgs, null);
+        try {
+            return c.getCount();
+        } finally {
+            c.close();
+        }
+    }
+
+    protected byte[] loadTestPhoto() throws IOException {
+        final Resources resources = getContext().getResources();
+        InputStream is =
+                resources.openRawResource(com.android.internal.R.drawable.ic_contact_picture);
+        ByteArrayOutputStream os = new ByteArrayOutputStream();
+        byte[] buffer = new byte[1000];
+        int count;
+        while((count = is.read(buffer)) != -1) {
+            os.write(buffer, 0, count);
+        }
+        return os.toByteArray();
+    }
+}
diff --git a/tests/src/com/android/providers/contacts/CallLogProviderTest.java b/tests/src/com/android/providers/contacts/CallLogProviderTest.java
new file mode 100644
index 0000000..75ebda9
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/CallLogProviderTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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.telephony.CallerInfo;
+import com.android.internal.telephony.Connection;
+
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.CallLog;
+import android.provider.ContactsContract;
+import android.provider.CallLog.Calls;
+import android.provider.Contacts.ContactMethods;
+import android.test.suitebuilder.annotation.LargeTest;
+
+/**
+ * Unit tests for {@link CallLogProvider}.
+ *
+ * Run the test like this:
+ * <code>
+ * adb shell am instrument -w \
+ *         com.android.providers.contacts.tests/android.test.InstrumentationTestRunner
+ * </code>
+ */
+@LargeTest
+public class CallLogProviderTest extends BaseContactsProvider2Test {
+
+    private static final boolean USE_LEGACY_PROVIDER = false;
+
+    @Override
+    protected Class<? extends ContentProvider> getProviderClass() {
+       return USE_LEGACY_PROVIDER ? ContactsProvider.class : ContactsProvider2.class;
+    }
+
+    @Override
+    protected String getAuthority() {
+        return ContactsContract.AUTHORITY;
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        if (USE_LEGACY_PROVIDER) {
+            addAuthority(CallLog.AUTHORITY);
+        } else {
+            addProvider(TestCallLogProvider.class, CallLog.AUTHORITY);
+        }
+    }
+
+    public void testInsert() {
+        ContentValues values = new ContentValues();
+        putCallValues(values);
+        Uri uri = mResolver.insert(Calls.CONTENT_URI, values);
+        assertStoredValues(uri, values);
+        assertSelection(uri, values, Calls._ID, ContentUris.parseId(uri));
+    }
+
+    public void testUpdate() {
+        ContentValues values = new ContentValues();
+        putCallValues(values);
+        Uri uri = mResolver.insert(Calls.CONTENT_URI, values);
+
+        values.clear();
+        values.put(Calls.TYPE, Calls.OUTGOING_TYPE);
+        values.put(Calls.NUMBER, "1-800-263-7643");
+        values.put(Calls.DATE, 2000);
+        values.put(Calls.DURATION, 40);
+        values.put(Calls.CACHED_NAME, "1-800-GOOG-411");
+        values.put(Calls.CACHED_NUMBER_TYPE, ContactMethods.TYPE_CUSTOM);
+        values.put(Calls.CACHED_NUMBER_LABEL, "Directory");
+
+        int count = mResolver.update(uri, values, null, null);
+        assertEquals(1, count);
+        assertStoredValues(uri, values);
+    }
+
+    public void testDelete() {
+        ContentValues values = new ContentValues();
+        putCallValues(values);
+        Uri uri = mResolver.insert(Calls.CONTENT_URI, values);
+        try {
+            mResolver.delete(uri, null, null);
+            fail();
+        } catch (UnsupportedOperationException ex) {
+            // Expected
+        }
+
+        int count = mResolver.delete(Calls.CONTENT_URI, Calls._ID + "="
+                + ContentUris.parseId(uri), null);
+        assertEquals(1, count);
+        assertEquals(0, getCount(uri, null, null));
+    }
+
+    public void testCallLogFilter() {
+        ContentValues values = new ContentValues();
+        putCallValues(values);
+        mResolver.insert(Calls.CONTENT_URI, values);
+
+        Uri filterUri = Uri.withAppendedPath(Calls.CONTENT_FILTER_URI, "1-800-4664-411");
+        Cursor c = mResolver.query(filterUri, null, null, null, null);
+        assertEquals(1, c.getCount());
+        c.moveToFirst();
+        assertCursorValues(c, values);
+        c.close();
+
+        filterUri = Uri.withAppendedPath(Calls.CONTENT_FILTER_URI, "1-888-4664-411");
+        c = mResolver.query(filterUri, null, null, null, null);
+        assertEquals(0, c.getCount());
+        c.close();
+    }
+
+    public void testAddCall() {
+        CallerInfo ci = new CallerInfo();
+        ci.name = "1-800-GOOG-411";
+        ci.numberType = ContactMethods.TYPE_CUSTOM;
+        ci.numberLabel = "Directory";
+        Uri uri = Calls.addCall(ci, getMockContext(), "1-800-263-7643",
+                Connection.PRESENTATION_ALLOWED, Calls.OUTGOING_TYPE, 2000, 40);
+
+        ContentValues values = new ContentValues();
+        values.put(Calls.TYPE, Calls.OUTGOING_TYPE);
+        values.put(Calls.NUMBER, "1-800-263-7643");
+        values.put(Calls.DATE, 2000);
+        values.put(Calls.DURATION, 40);
+        values.put(Calls.CACHED_NAME, "1-800-GOOG-411");
+        values.put(Calls.CACHED_NUMBER_TYPE, ContactMethods.TYPE_CUSTOM);
+        values.put(Calls.CACHED_NUMBER_LABEL, "Directory");
+        assertStoredValues(uri, values);
+    }
+
+    private void putCallValues(ContentValues values) {
+        values.put(Calls.TYPE, Calls.INCOMING_TYPE);
+        values.put(Calls.NUMBER, "1-800-4664-411");
+        values.put(Calls.DATE, 1000);
+        values.put(Calls.DURATION, 30);
+        values.put(Calls.NEW, 1);
+    }
+
+    public static class TestCallLogProvider extends CallLogProvider {
+        private static OpenHelper mOpenHelper;
+
+        @Override
+        protected OpenHelper getOpenHelper(final Context context) {
+            if (mOpenHelper == null) {
+                mOpenHelper = new OpenHelper(context);
+            }
+            return mOpenHelper;
+        }
+    }
+}
+
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..06dc197
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/ContactAggregatorTest.java
@@ -0,0 +1,563 @@
+/*
+ * 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.Contacts;
+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.CONTACT_ID,
+            AggregationExceptions.RAW_CONTACT_ID
+    };
+
+    public void testCrudAggregationExceptions() throws Exception {
+        long rawContactId1 = createRawContact();
+        long contactId = queryContactId(rawContactId1);
+        long rawContactId2 = createRawContact();
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, contactId, rawContactId2);
+
+        // Refetch the row we have just inserted
+        Cursor c = mResolver.query(AggregationExceptions.CONTENT_URI,
+                AGGREGATION_EXCEPTION_PROJECTION, AggregationExceptions.CONTACT_ID + "="
+                        + contactId, null, null);
+
+        assertTrue(c.moveToFirst());
+        assertEquals(AggregationExceptions.TYPE_KEEP_IN, c.getInt(0));
+        assertEquals(contactId, c.getLong(1));
+        assertEquals(rawContactId2, c.getLong(2));
+        assertFalse(c.moveToNext());
+        c.close();
+
+        // Change from TYPE_KEEP_IN to TYPE_KEEP_OUT
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, contactId, rawContactId2);
+
+        c = mResolver.query(AggregationExceptions.CONTENT_URI,
+                AGGREGATION_EXCEPTION_PROJECTION, AggregationExceptions.CONTACT_ID + "="
+                        + contactId, null, null);
+
+        assertTrue(c.moveToFirst());
+        assertEquals(AggregationExceptions.TYPE_KEEP_OUT, c.getInt(0));
+        assertEquals(contactId, c.getLong(1));
+        assertEquals(rawContactId2, c.getLong(2));
+        assertFalse(c.moveToNext());
+        c.close();
+
+        // Delete the rule
+        setAggregationException(AggregationExceptions.TYPE_AUTOMATIC, contactId, rawContactId2);
+
+        // Verify that the row is gone
+        c = mResolver.query(AggregationExceptions.CONTENT_URI,
+                AGGREGATION_EXCEPTION_PROJECTION, AggregationExceptions.CONTACT_ID + "="
+                        + contactId, null, null);
+        assertFalse(c.moveToFirst());
+        c.close();
+    }
+
+    public void testAggregationCreatesNewAggregate() {
+        long rawContactId = createRawContact();
+
+        Uri resultUri = insertStructuredName(rawContactId, "Johna", "Smitha");
+
+        // Parse the URI and confirm that it contains an ID
+        assertTrue(ContentUris.parseId(resultUri) != 0);
+
+        long contactId = queryContactId(rawContactId);
+        assertTrue(contactId != 0);
+
+        String displayName = queryDisplayName(contactId);
+        assertEquals("Johna Smitha", displayName);
+    }
+
+    public void testAggregationOfExactFullNameMatch() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Johnb", "Smithb");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Johnb", "Smithb");
+
+        assertAggregated(rawContactId1, rawContactId2, "Johnb Smithb");
+    }
+
+    public void testAggregationOfCaseInsensitiveFullNameMatch() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Johnc", "Smithc");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Johnc", "smithc");
+
+        assertAggregated(rawContactId1, rawContactId2, "Johnc Smithc");
+    }
+
+    public void testAggregationOfLastNameMatch() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, null, "Johnd");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, null, "johnd");
+
+        assertAggregated(rawContactId1, rawContactId2, "Johnd");
+    }
+
+    public void testNonAggregationOfFirstNameMatch() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Johne", "Smithe");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Johne", null);
+
+        assertNotAggregated(rawContactId1, rawContactId2);
+    }
+
+    // TODO: should this be allowed to match?
+    public void testNonAggregationOfLastNameMatch() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Johnf", "Smithf");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, null, "Smithf");
+
+        assertNotAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationOfConcatenatedFullNameMatch() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Johng", "Smithg");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "johngsmithg", null);
+
+        assertAggregated(rawContactId1, rawContactId2, "Johng Smithg");
+    }
+
+    public void testAggregationOfNormalizedFullNameMatch() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "H\u00e9l\u00e8ne", "Bj\u00f8rn");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "helene bjorn", null);
+
+        assertAggregated(rawContactId1, rawContactId2, "H\u00e9l\u00e8ne Bj\u00f8rn");
+    }
+
+    public void testAggregationBasedOnPhoneNumberNoNameData() {
+        long rawContactId1 = createRawContact();
+        insertPhoneNumber(rawContactId1, "(888)555-1231");
+
+        long rawContactId2 = createRawContact();
+        insertPhoneNumber(rawContactId2, "1(888)555-1231");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationBasedOnPhoneNumberWhenTargetAggregateHasNoName() {
+        long rawContactId1 = createRawContact();
+        insertPhoneNumber(rawContactId1, "(888)555-1232");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Johnl", "Smithl");
+        insertPhoneNumber(rawContactId2, "1(888)555-1232");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationBasedOnPhoneNumberWhenNewContactHasNoName() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Johnm", "Smithm");
+        insertPhoneNumber(rawContactId1, "(888)555-1233");
+
+        long rawContactId2 = createRawContact();
+        insertPhoneNumber(rawContactId2, "1(888)555-1233");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationBasedOnPhoneNumberWithSimilarNames() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Ogre", "Hunter");
+        insertPhoneNumber(rawContactId1, "(888)555-1234");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Opra", "Humper");
+        insertPhoneNumber(rawContactId2, "1(888)555-1234");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationBasedOnPhoneNumberWithDifferentNames() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Baby", "Bear");
+        insertPhoneNumber(rawContactId1, "(888)555-1235");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Blind", "Mouse");
+        insertPhoneNumber(rawContactId2, "1(888)555-1235");
+
+        assertNotAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationBasedOnEmailNoNameData() {
+        long rawContactId1 = createRawContact();
+        insertEmail(rawContactId1, "lightning@android.com");
+
+        long rawContactId2 = createRawContact();
+        insertEmail(rawContactId2, "lightning@android.com");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationBasedOnEmailWhenTargetAggregateHasNoName() {
+        long rawContactId1 = createRawContact();
+        insertEmail(rawContactId1, "mcqueen@android.com");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Lightning", "McQueen");
+        insertEmail(rawContactId2, "mcqueen@android.com");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationBasedOnEmailWhenNewContactHasNoName() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Doc", "Hudson");
+        insertEmail(rawContactId1, "doc@android.com");
+
+        long rawContactId2 = createRawContact();
+        insertEmail(rawContactId2, "doc@android.com");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationBasedOnEmailWithSimilarNames() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Sally", "Carrera");
+        insertEmail(rawContactId1, "sally@android.com");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Sallie", "Carerra");
+        insertEmail(rawContactId2, "sally@android.com");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationBasedOnEmailWithDifferentNames() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Chick", "Hicks");
+        insertEmail(rawContactId1, "hicky@android.com");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Luigi", "Guido");
+        insertEmail(rawContactId2, "hicky@android.com");
+
+        assertNotAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationByCommonNicknameWithLastName() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Bill", "Gore");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "William", "Gore");
+
+        assertAggregated(rawContactId1, rawContactId2, "William Gore");
+    }
+
+    public void testAggregationByCommonNicknameOnly() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Lawrence", null);
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Larry", null);
+
+        assertAggregated(rawContactId1, rawContactId2, "Lawrence");
+    }
+
+    public void testAggregationByNicknameNoStructuredName() {
+        long rawContactId1 = createRawContact();
+        insertNickname(rawContactId1, "Frozone");
+
+        long rawContactId2 = createRawContact();
+        insertNickname(rawContactId2, "Frozone");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationByNicknameWithSimilarNames() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Buddy", "Pine");
+        insertNickname(rawContactId1, "Syndrome");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Body", "Pane");
+        insertNickname(rawContactId2, "Syndrome");
+
+        assertAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationByNicknameWithDifferentNames() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Helen", "Parr");
+        insertNickname(rawContactId1, "Elastigirl");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Shawn", "Johnson");
+        insertNickname(rawContactId2, "Elastigirl");
+
+        assertNotAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationExceptionKeepIn() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Johnk", "Smithk");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Johnkx", "Smithkx");
+
+        long contactId1 = queryContactId(rawContactId1);
+        long contactId2 = queryContactId(rawContactId2);
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN,
+                queryContactId(rawContactId1), rawContactId2);
+
+        assertAggregated(rawContactId1, rawContactId2, "Johnkx Smithkx");
+
+        // Assert that the empty aggregate got removed
+        long newContactId1 = queryContactId(rawContactId1);
+        if (contactId1 != newContactId1) {
+            Cursor cursor = queryContact(contactId1);
+            assertFalse(cursor.moveToFirst());
+            cursor.close();
+        } else {
+            Cursor cursor = queryContact(contactId2);
+            assertFalse(cursor.moveToFirst());
+            cursor.close();
+        }
+    }
+
+    public void testAggregationExceptionKeepOut() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Johnh", "Smithh");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Johnh", "Smithh");
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT,
+                queryContactId(rawContactId1), rawContactId2);
+
+        assertNotAggregated(rawContactId1, rawContactId2);
+    }
+
+    public void testAggregationExceptionKeepOutCheckUpdatesDisplayName() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Johni", "Smithi");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Johnj", "Smithj");
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN,
+                queryContactId(rawContactId1), rawContactId2);
+
+        assertAggregated(rawContactId1, rawContactId2, "Johnj Smithj");
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT,
+                queryContactId(rawContactId1), rawContactId2);
+
+        assertNotAggregated(rawContactId1, rawContactId2);
+
+        String displayName1 = queryDisplayName(queryContactId(rawContactId1));
+        assertEquals("Johni Smithi", displayName1);
+
+        String displayName2 = queryDisplayName(queryContactId(rawContactId2));
+        assertEquals("Johnj Smithj", displayName2);
+    }
+
+    public void testAggregationSuggestionsBasedOnName() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Duane", null);
+
+        // Exact name match
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Duane", null);
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT,
+                queryContactId(rawContactId1), rawContactId2);
+
+        // Edit distance == 0.84
+        long rawContactId3 = createRawContact();
+        insertStructuredName(rawContactId3, "Dwayne", null);
+
+        // Edit distance == 0.6
+        long rawContactId4 = createRawContact();
+        insertStructuredName(rawContactId4, "Donny", null);
+
+        long contactId1 = queryContactId(rawContactId1);
+        long contactId2 = queryContactId(rawContactId2);
+        long contactId3 = queryContactId(rawContactId3);
+
+        assertSuggestions(contactId1, contactId2, contactId3);
+    }
+
+    public void testAggregationSuggestionsBasedOnPhoneNumber() {
+
+        // Create two contacts that would not be aggregated because of name mismatch
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Lord", "Farquaad");
+        insertPhoneNumber(rawContactId1, "(888)555-1236");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Talking", "Donkey");
+        insertPhoneNumber(rawContactId2, "1(888)555-1236");
+
+        long contactId1 = queryContactId(rawContactId1);
+        long contactId2 = queryContactId(rawContactId2);
+        assertTrue(contactId1 != contactId2);
+
+        assertSuggestions(contactId1, contactId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnEmailAddress() {
+
+        // Create two contacts that would not be aggregated because of name mismatch
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Carl", "Fredricksen");
+        insertEmail(rawContactId1, "up@android.com");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Charles", "Muntz");
+        insertEmail(rawContactId2, "up@android.com");
+
+        long contactId1 = queryContactId(rawContactId1);
+        long contactId2 = queryContactId(rawContactId2);
+        assertTrue(contactId1 != contactId2);
+
+        assertSuggestions(contactId1, contactId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnEmailAddressApproximateMatch() {
+
+        // Create two contacts that would not be aggregated because of name mismatch
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Bob", null);
+        insertEmail(rawContactId1, "incredible2004@android.com");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Lucius", "Best");
+        insertEmail(rawContactId2, "incrediball@androidd.com");
+
+        long contactId1 = queryContactId(rawContactId1);
+        long contactId2 = queryContactId(rawContactId2);
+        assertTrue(contactId1 != contactId2);
+
+        assertSuggestions(contactId1, contactId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnNickname() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Peter", "Parker");
+        insertNickname(rawContactId1, "Spider-Man");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Manny", "Spider");
+
+        long contactId1 = queryContactId(rawContactId1);
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, contactId1, rawContactId2);
+
+        long contactId2 = queryContactId(rawContactId2);
+        assertSuggestions(contactId1, contactId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnNicknameMatchingName() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Clark", "Kent");
+        insertNickname(rawContactId1, "Superman");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Roy", "Williams");
+        insertNickname(rawContactId2, "superman");
+
+        long contactId1 = queryContactId(rawContactId1);
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, contactId1, rawContactId2);
+
+        long contactId2 = queryContactId(rawContactId2);
+        assertSuggestions(contactId1, contactId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnCommonNickname() {
+        long rawContactId1 = createRawContact();
+        insertStructuredName(rawContactId1, "Dick", "Cherry");
+
+        long rawContactId2 = createRawContact();
+        insertStructuredName(rawContactId2, "Richard", "Cherry");
+
+        long contactId1 = queryContactId(rawContactId1);
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, contactId1, rawContactId2);
+
+        long contactId2 = queryContactId(rawContactId2);
+        assertSuggestions(contactId1, contactId2);
+    }
+
+    public void testChoosePhoto() {
+        long rawContactId1 = createRawContact();
+        setContactAccountName(rawContactId1, "donut");
+        long donutId = ContentUris.parseId(insertPhoto(rawContactId1));
+        long contactId = queryContactId(rawContactId1);
+
+        long rawContactId2 = createRawContact();
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, contactId, rawContactId2);
+        setContactAccountName(rawContactId2, "cupcake");
+        long cupcakeId = ContentUris.parseId(insertPhoto(rawContactId2));
+
+        long rawContactId3 = createRawContact();
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, contactId, rawContactId3);
+        setContactAccountName(rawContactId3, "flan");
+        long flanId = ContentUris.parseId(insertPhoto(rawContactId3));
+
+        assertEquals(cupcakeId, queryPhotoId(queryContactId(rawContactId2)));
+    }
+
+    private void assertSuggestions(long contactId, long... suggestions) {
+        final Uri aggregateUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
+        Uri uri = Uri.withAppendedPath(aggregateUri,
+                Contacts.AggregationSuggestions.CONTENT_DIRECTORY);
+        final Cursor cursor = mResolver.query(uri, new String[] { Contacts._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..eed7b43
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/ContactsActor.java
@@ -0,0 +1,313 @@
+/*
+ * 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.ContentProvider;
+import android.content.ContentResolver;
+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.CommonDataKinds;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.RawContacts;
+import android.test.IsolatedContext;
+import android.test.RenamingDelegatingContext;
+import android.test.mock.MockContentResolver;
+import android.test.mock.MockContext;
+import android.test.mock.MockPackageManager;
+
+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 ContentProvider provider;
+
+    private IsolatedContext mProviderContext;
+
+    /**
+     * 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,
+            Class<? extends ContentProvider> providerClass, String authority) throws Exception {
+        resolver = new MockContentResolver();
+        context = new RestrictionMockContext(overallContext, packageName, resolver);
+        this.packageName = packageName;
+
+        RenamingDelegatingContext targetContextWrapper = new RenamingDelegatingContext(context,
+                overallContext, FILENAME_PREFIX);
+        mProviderContext = new IsolatedContext(resolver, targetContextWrapper);
+        provider = addProvider(providerClass, authority);
+    }
+
+    public void addAuthority(String authority) {
+        resolver.addProvider(authority, provider);
+    }
+
+    public ContentProvider addProvider(Class<? extends ContentProvider> providerClass,
+            String authority) throws Exception {
+        ContentProvider provider = providerClass.newInstance();
+        provider.attachInfo(mProviderContext, null);
+        resolver.addProvider(authority, provider);
+        return 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;
+        private final ContentResolver mResolver;
+
+        /**
+         * Create a {@link Context} under the given package name.
+         */
+        public RestrictionMockContext(Context overallContext, String reportedPackageName,
+                ContentResolver resolver) {
+            mOverallContext = overallContext;
+            mReportedPackageName = reportedPackageName;
+            mResolver = resolver;
+            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();
+        }
+
+        @Override
+        public ContentResolver getContentResolver() {
+            return mResolver;
+        }
+    }
+
+    /**
+     * 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();
+        if (isRestricted) {
+            values.put(RawContacts.IS_RESTRICTED, 1);
+        }
+
+        Uri contactUri = resolver.insert(RawContacts.CONTENT_URI, values);
+        return ContentUris.parseId(contactUri);
+    }
+
+    public long createName(long contactId, String name) {
+        final ContentValues values = new ContentValues();
+        values.put(Data.RAW_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(RawContacts.CONTENT_URI,
+                contactId), RawContacts.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.RAW_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.TYPE,
+                ContactsContract.CommonDataKinds.Phone.TYPE_HOME);
+        values.put(ContactsContract.CommonDataKinds.Phone.NUMBER, phoneNumber);
+        Uri insertUri = Uri.withAppendedPath(ContentUris.withAppendedId(RawContacts.CONTENT_URI,
+                contactId), RawContacts.Data.CONTENT_DIRECTORY);
+        Uri dataUri = resolver.insert(insertUri, values);
+        return ContentUris.parseId(dataUri);
+    }
+
+    public void updateException(String packageProvider, String packageClient, boolean allowAccess) {
+        throw new UnsupportedOperationException("RestrictionExceptions are hard-coded");
+    }
+
+    public long getContactForRawContact(long rawContactId) {
+        Uri contactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId);
+        final Cursor cursor = resolver.query(contactUri, Projections.PROJ_RAW_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_ID);
+        cursor.close();
+        return aggId;
+    }
+
+    public int getDataCountForContact(long contactId) {
+        Uri contactUri = Uri.withAppendedPath(ContentUris.withAppendedId(Contacts.CONTENT_URI,
+                contactId), Contacts.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 contactId) {
+        Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
+        final Cursor cursor = resolver.query(contactUri, Projections.PROJ_CONTACTS, null,
+                null, null);
+        long primaryPhoneId = -1;
+        if (cursor.moveToFirst()) {
+            primaryPhoneId = cursor.getLong(Projections.COL_CONTACTS_PRIMARY_PHONE_ID);
+        }
+        cursor.close();
+        return primaryPhoneId;
+    }
+
+    public long createGroup(String groupName) {
+        final ContentValues values = new ContentValues();
+        values.put(ContactsContract.Groups.RES_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 rawContactId, long groupId) {
+        final ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE);
+        values.put(CommonDataKinds.GroupMembership.GROUP_ROW_ID, groupId);
+        Uri insertUri = Uri.withAppendedPath(ContentUris.withAppendedId(RawContacts.CONTENT_URI,
+                rawContactId), RawContacts.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_RAW_CONTACTS = new String[] {
+                RawContacts.CONTACT_ID
+        };
+
+        static final int COL_CONTACTS_ID = 0;
+
+        static final String[] PROJ_CONTACTS = new String[] {
+                Contacts.PRIMARY_PHONE_ID
+        };
+
+        static final int COL_CONTACTS_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..f6d5bd7
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/ContactsProvider2Test.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.internal.util.ArrayUtils;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Entity;
+import android.content.EntityIterator;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Presence;
+import android.provider.ContactsContract.RawContacts;
+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.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.test.suitebuilder.annotation.LargeTest;
+
+/**
+ * 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 rawContactId = createRawContact();
+        ContentValues values = new ContentValues();
+        values.put(StructuredName.DISPLAY_NAME, "Mr.John Kevin von Smith, Jr.");
+        insertStructuredName(rawContactId, values);
+
+        assertStructuredName(rawContactId, "Mr", "John", "Kevin", "von Smith", "Jr");
+    }
+
+    public void testDisplayNameParsingWhenPartsSpecified() {
+        long rawContactId = createRawContact();
+        ContentValues values = new ContentValues();
+        values.put(StructuredName.DISPLAY_NAME, "Mr.John Kevin von Smith, Jr.");
+        values.put(StructuredName.FAMILY_NAME, "Johnson");
+        insertStructuredName(rawContactId, values);
+
+        assertStructuredName(rawContactId, null, null, null, "Johnson", null);
+    }
+
+    public void testSendToVoicemailDefault() {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+
+        Cursor c = queryContact(contactId);
+        assertTrue(c.moveToNext());
+        int sendToVoicemail = c.getInt(c.getColumnIndex(Contacts.SEND_TO_VOICEMAIL));
+        assertEquals(0, sendToVoicemail);
+        c.close();
+    }
+
+    public void testSetSendToVoicemailAndRingtone() {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+
+        updateSendToVoicemailAndRingtone(contactId, true, "foo");
+        assertSendToVoicemailAndRingtone(contactId, true, "foo");
+    }
+
+    public void testSendToVoicemailAndRingtoneAfterAggregation() {
+        long rawContactId1 = createRawContactWithName();
+        long contactId1 = queryContactId(rawContactId1);
+        updateSendToVoicemailAndRingtone(contactId1, true, "foo");
+
+        long rawContactId2 = createRawContactWithName();
+        long contactId2 = queryContactId(rawContactId2);
+        updateSendToVoicemailAndRingtone(contactId2, true, "bar");
+
+        // Aggregate them
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, contactId1, rawContactId2);
+
+        // Both contacts had "send to VM", the contact now has the same value
+        assertSendToVoicemailAndRingtone(contactId1, true, "foo,bar"); // Either foo or bar
+    }
+
+    public void testDoNotSendToVoicemailAfterAggregation() {
+        long rawContactId1 = createRawContactWithName();
+        long contactId1 = queryContactId(rawContactId1);
+        updateSendToVoicemailAndRingtone(contactId1, true, null);
+
+        long rawContactId2 = createRawContactWithName();
+        long contactId2 = queryContactId(rawContactId2);
+        updateSendToVoicemailAndRingtone(contactId2, false, null);
+
+        // Aggregate them
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, contactId1, rawContactId2);
+
+        // Since one of the contacts had "don't send to VM" that setting wins for the aggregate
+        assertSendToVoicemailAndRingtone(contactId1, false, null);
+    }
+
+    public void testSetSendToVoicemailAndRingtonePreservedAfterJoinAndSplit() {
+        long rawContactId1 = createRawContactWithName();
+        long contactId1 = queryContactId(rawContactId1);
+        updateSendToVoicemailAndRingtone(contactId1, true, "foo");
+
+        long rawContactId2 = createRawContactWithName();
+        long contactId2 = queryContactId(rawContactId2);
+        updateSendToVoicemailAndRingtone(contactId2, false, "bar");
+
+        // Aggregate them
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, contactId1, rawContactId2);
+
+        // Split them
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, contactId1, rawContactId2);
+
+        assertSendToVoicemailAndRingtone(contactId1, true, "foo");
+        assertSendToVoicemailAndRingtone(queryContactId(rawContactId2), false, "bar");
+    }
+
+    public void testSinglePresenceRowPerContact() {
+        int protocol1 = Im.PROTOCOL_GOOGLE_TALK;
+        String handle1 = "test@gmail.com";
+
+        long rawContactId1 = createRawContact();
+        insertImHandle(rawContactId1, protocol1, handle1);
+
+        insertPresence(protocol1, handle1, Presence.AVAILABLE);
+        insertPresence(protocol1, handle1, Presence.AWAY);
+        insertPresence(protocol1, handle1, Presence.INVISIBLE);
+
+        Cursor c = queryContactSummary(queryContactId(rawContactId1),
+                new String[] {Presence.PRESENCE_STATUS});
+        assertEquals(c.getCount(), 1);
+
+        c.moveToFirst();
+        assertEquals(c.getInt(0), Presence.AVAILABLE);
+
+    }
+
+    private void updateSendToVoicemailAndRingtone(long contactId, boolean sendToVoicemail,
+            String ringtone) {
+        ContentValues values = new ContentValues();
+        values.put(Contacts.SEND_TO_VOICEMAIL, sendToVoicemail);
+        if (ringtone != null) {
+            values.put(Contacts.CUSTOM_RINGTONE, ringtone);
+        }
+
+        final Uri uri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
+        int count = mResolver.update(uri, values, null, null);
+        assertEquals(1, count);
+    }
+
+    private void assertSendToVoicemailAndRingtone(long contactId, boolean expectedSendToVoicemail,
+            String expectedRingtone) {
+        Cursor c = queryContact(contactId);
+        assertTrue(c.moveToNext());
+        int sendToVoicemail = c.getInt(c.getColumnIndex(Contacts.SEND_TO_VOICEMAIL));
+        assertEquals(expectedSendToVoicemail ? 1 : 0, sendToVoicemail);
+        String ringtone = c.getString(c.getColumnIndex(Contacts.CUSTOM_RINGTONE));
+        if (expectedRingtone == null) {
+            assertNull(ringtone);
+        } else {
+            assertTrue(ArrayUtils.contains(expectedRingtone.split(","), ringtone));
+        }
+        c.close();
+    }
+
+    public void testGroupCreationAfterMembershipInsert() {
+        long rawContactId1 = createRawContact(mAccount);
+        Uri groupMembershipUri = insertGroupMembership(rawContactId1, "gsid1");
+
+        long groupId = assertSingleGroup(NO_LONG, mAccount, "gsid1", null);
+        assertSingleGroupMembership(ContentUris.parseId(groupMembershipUri),
+                rawContactId1, groupId, "gsid1");
+    }
+
+    public void testGroupReuseAfterMembershipInsert() {
+        long rawContactId1 = createRawContact(mAccount);
+        long groupId1 = createGroup(mAccount, "gsid1", "title1");
+        Uri groupMembershipUri = insertGroupMembership(rawContactId1, "gsid1");
+
+        assertSingleGroup(groupId1, mAccount, "gsid1", "title1");
+        assertSingleGroupMembership(ContentUris.parseId(groupMembershipUri),
+                rawContactId1, groupId1, "gsid1");
+    }
+
+    public void testGroupInsertFailureOnGroupIdConflict() {
+        long rawContactId1 = createRawContact(mAccount);
+        long groupId1 = createGroup(mAccount, "gsid1", "title1");
+
+        ContentValues values = new ContentValues();
+        values.put(GroupMembership.RAW_CONTACT_ID, rawContactId1);
+        values.put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
+        values.put(GroupMembership.GROUP_SOURCE_ID, "gsid1");
+        values.put(GroupMembership.GROUP_ROW_ID, groupId1);
+        try {
+            mResolver.insert(Data.CONTENT_URI, values);
+            fail("the insert was expected to fail, but it succeeded");
+        } catch (IllegalArgumentException e) {
+            // this was expected
+        }
+    }
+
+    public void testContentEntityIterator() throws RemoteException {
+        // create multiple contacts and check that the selected ones are returned
+        long id;
+
+        long groupId1 = createGroup(mAccount, "gsid1", "title1");
+        long groupId2 = createGroup(mAccount, "gsid2", "title2");
+
+        long c0 = id = createRawContact(mAccount, RawContacts.SOURCE_ID, "c0");
+        Uri id_0_0 = insertGroupMembership(id, "gsid1");
+        Uri id_0_1 = insertEmail(id, "c0@email.com");
+        Uri id_0_2 = insertPhoneNumber(id, "5551212c0");
+
+        long c1 = id = createRawContact(mAccount, RawContacts.SOURCE_ID, "c1");
+        Uri id_1_0 = insertGroupMembership(id, "gsid1");
+        Uri id_1_1 = insertGroupMembership(id, "gsid2");
+        Uri id_1_2 = insertEmail(id, "c1@email.com");
+        Uri id_1_3 = insertPhoneNumber(id, "5551212c1");
+
+        long c2 = id = createRawContact(mAccount, RawContacts.SOURCE_ID, "c2");
+        Uri id_2_0 = insertGroupMembership(id, "gsid1");
+        Uri id_2_1 = insertEmail(id, "c2@email.com");
+        Uri id_2_2 = insertPhoneNumber(id, "5551212c2");
+
+        long c3 = id = createRawContact(mAccount);
+        Uri id_3_0 = insertGroupMembership(id, groupId2);
+        Uri id_3_1 = insertEmail(id, "c3@email.com");
+        Uri id_3_2 = insertPhoneNumber(id, "5551212c3");
+
+        EntityIterator iterator = mResolver.queryEntities(
+                maybeAddAccountQueryParameters(RawContacts.CONTENT_URI, mAccount),
+                RawContacts.SOURCE_ID + " in ('c1', 'c2', 'c3')", null, null);
+        Entity entity;
+        ContentValues[] subValues;
+        entity = iterator.next();
+        assertEquals(c1, (long) entity.getEntityValues().getAsLong(RawContacts._ID));
+        subValues = asSortedContentValuesArray(entity.getSubValues());
+        assertEquals(4, subValues.length);
+        assertDataRow(subValues[0], GroupMembership.CONTENT_ITEM_TYPE,
+                Data._ID, id_1_0,
+                GroupMembership.GROUP_ROW_ID, groupId1,
+                GroupMembership.GROUP_SOURCE_ID, "gsid1");
+        assertDataRow(subValues[1], GroupMembership.CONTENT_ITEM_TYPE,
+                Data._ID, id_1_1,
+                GroupMembership.GROUP_ROW_ID, groupId2,
+                GroupMembership.GROUP_SOURCE_ID, "gsid2");
+        assertDataRow(subValues[2], Email.CONTENT_ITEM_TYPE,
+                Data._ID, id_1_2,
+                Email.DATA, "c1@email.com");
+        assertDataRow(subValues[3], Phone.CONTENT_ITEM_TYPE,
+                Data._ID, id_1_3,
+                Email.DATA, "5551212c1");
+
+        entity = iterator.next();
+        assertEquals(c2, (long) entity.getEntityValues().getAsLong(RawContacts._ID));
+        subValues = asSortedContentValuesArray(entity.getSubValues());
+        assertEquals(3, subValues.length);
+        assertDataRow(subValues[0], GroupMembership.CONTENT_ITEM_TYPE,
+                Data._ID, id_2_0,
+                GroupMembership.GROUP_ROW_ID, groupId1,
+                GroupMembership.GROUP_SOURCE_ID, "gsid1");
+        assertDataRow(subValues[1], Email.CONTENT_ITEM_TYPE,
+                Data._ID, id_2_1,
+                Email.DATA, "c2@email.com");
+        assertDataRow(subValues[2], Phone.CONTENT_ITEM_TYPE,
+                Data._ID, id_2_2,
+                Email.DATA, "5551212c2");
+
+        entity = iterator.next();
+        assertEquals(c3, (long) entity.getEntityValues().getAsLong(RawContacts._ID));
+        subValues = asSortedContentValuesArray(entity.getSubValues());
+        assertEquals(3, subValues.length);
+        assertDataRow(subValues[0], GroupMembership.CONTENT_ITEM_TYPE,
+                Data._ID, id_3_0,
+                GroupMembership.GROUP_ROW_ID, groupId2,
+                GroupMembership.GROUP_SOURCE_ID, "gsid2");
+        assertDataRow(subValues[1], Email.CONTENT_ITEM_TYPE,
+                Data._ID, id_3_1,
+                Email.DATA, "c3@email.com");
+        assertDataRow(subValues[2], Phone.CONTENT_ITEM_TYPE,
+                Data._ID, id_3_2,
+                Email.DATA, "5551212c3");
+
+        assertFalse(iterator.hasNext());
+    }
+
+    public void testDataCreateUpdateDeleteByMimeType() throws Exception {
+        long rawContactId = createRawContact();
+
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, "testmimetype");
+        values.put(Data.RES_PACKAGE, "oldpackage");
+        values.put(Data.IS_PRIMARY, 1);
+        values.put(Data.IS_SUPER_PRIMARY, 1);
+        values.put(Data.DATA1, "old1");
+        values.put(Data.DATA2, "old2");
+        values.put(Data.DATA3, "old3");
+        values.put(Data.DATA4, "old4");
+        values.put(Data.DATA5, "old5");
+        values.put(Data.DATA6, "old6");
+        values.put(Data.DATA7, "old7");
+        values.put(Data.DATA8, "old8");
+        values.put(Data.DATA9, "old9");
+        values.put(Data.DATA10, "old10");
+        values.put(Data.DATA11, "old11");
+        values.put(Data.DATA12, "old12");
+        values.put(Data.DATA13, "old13");
+        values.put(Data.DATA14, "old14");
+        values.put(Data.DATA15, "old15");
+        Uri uri = mResolver.insert(Data.CONTENT_URI, values);
+        assertStoredValues(uri, values);
+
+        values.clear();
+        values.put(Data.RES_PACKAGE, "newpackage");
+        values.put(Data.IS_PRIMARY, 0);
+        values.put(Data.IS_SUPER_PRIMARY, 0);
+        values.put(Data.DATA1, "new1");
+        values.put(Data.DATA2, "new2");
+        values.put(Data.DATA3, "new3");
+        values.put(Data.DATA4, "new4");
+        values.put(Data.DATA5, "new5");
+        values.put(Data.DATA6, "new6");
+        values.put(Data.DATA7, "new7");
+        values.put(Data.DATA8, "new8");
+        values.put(Data.DATA9, "new9");
+        values.put(Data.DATA10, "new10");
+        values.put(Data.DATA11, "new11");
+        values.put(Data.DATA12, "new12");
+        values.put(Data.DATA13, "new13");
+        values.put(Data.DATA14, "new14");
+        values.put(Data.DATA15, "new15");
+        mResolver.update(Data.CONTENT_URI, values, Data.RAW_CONTACT_ID + "=" + rawContactId +
+                " AND " + Data.MIMETYPE + "='testmimetype'", null);
+
+        // Should not be able to change IS_PRIMARY and IS_SUPER_PRIMARY by the above update
+        values.put(Data.IS_PRIMARY, 1);
+        values.put(Data.IS_SUPER_PRIMARY, 1);
+        assertStoredValues(uri, values);
+
+        int count = mResolver.delete(Data.CONTENT_URI, Data.RAW_CONTACT_ID + "=" + rawContactId
+                + " AND " + Data.MIMETYPE + "='testmimetype'", null);
+        assertEquals(1, count);
+        assertEquals(0, getCount(Data.CONTENT_URI, Data.RAW_CONTACT_ID + "=" + rawContactId
+                        + " AND " + Data.MIMETYPE + "='testmimetype'", null));
+    }
+
+    public void testRawContactDeletion() {
+        long rawContactId = createRawContact();
+        Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId);
+
+        insertImHandle(rawContactId, Im.PROTOCOL_GOOGLE_TALK, "deleteme@android.com");
+        insertPresence(Im.PROTOCOL_GOOGLE_TALK, "deleteme@android.com", Presence.AVAILABLE);
+        assertEquals(1, getCount(Uri.withAppendedPath(uri, RawContacts.Data.CONTENT_DIRECTORY),
+                null, null));
+        assertEquals(1, getCount(Presence.CONTENT_URI, Presence.RAW_CONTACT_ID + "=" + rawContactId,
+                null));
+
+        mResolver.delete(uri, null, null);
+
+        assertStoredValues(uri, RawContacts.DELETED, "1");
+
+        Uri permanentDeletionUri = uri.buildUpon().appendQueryParameter(
+                RawContacts.DELETE_PERMANENTLY, "true").build();
+        mResolver.delete(permanentDeletionUri, null, null);
+        assertEquals(0, getCount(uri, null, null));
+        assertEquals(0, getCount(Uri.withAppendedPath(uri, RawContacts.Data.CONTENT_DIRECTORY),
+                null, null));
+        assertEquals(0, getCount(Presence.CONTENT_URI, Presence.RAW_CONTACT_ID + "=" + rawContactId,
+                null));
+    }
+
+    public void testRawContactDirtySetOnChange() {
+        Uri uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI,
+                createRawContact(mAccount));
+        assertDirty(uri, true);
+        clearDirty(uri);
+        assertDirty(uri, false);
+    }
+
+    public void testRawContactDirtyAndVersion() {
+        final long rawContactId = createRawContact(mAccount);
+        Uri uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, rawContactId);
+        assertDirty(uri, true);
+        long version = getVersion(uri);
+
+        ContentValues values = new ContentValues();
+        values.put(ContactsContract.RawContacts.DIRTY, 0);
+        values.put(ContactsContract.RawContacts.SEND_TO_VOICEMAIL, 1);
+        values.put(ContactsContract.RawContacts.AGGREGATION_MODE,
+                RawContacts.AGGREGATION_MODE_IMMEDITATE);
+        values.put(ContactsContract.RawContacts.STARRED, 1);
+        assertEquals(1, mResolver.update(uri, values, null, null));
+        assertEquals(version, getVersion(uri));
+
+        assertDirty(uri, false);
+
+        Uri emailUri = insertEmail(rawContactId, "goo@woo.com");
+        assertDirty(uri, true);
+        ++version;
+        assertEquals(version, getVersion(uri));
+        clearDirty(uri);
+
+        values = new ContentValues();
+        values.put(Email.DATA, "goo@hoo.com");
+        mResolver.update(emailUri, values, null, null);
+        assertDirty(uri, true);
+        ++version;
+        assertEquals(version, getVersion(uri));
+        clearDirty(uri);
+
+        mResolver.delete(emailUri, null, null);
+        assertDirty(uri, true);
+        ++version;
+        assertEquals(version, getVersion(uri));
+    }
+
+    public void testRawContactClearDirty() {
+        final long rawContactId = createRawContact(mAccount);
+        Uri uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI,
+                rawContactId);
+        long version = getVersion(uri);
+        insertEmail(rawContactId, "goo@woo.com");
+        assertDirty(uri, true);
+        version++;
+        assertEquals(version, getVersion(uri));
+
+        clearDirty(uri);
+        assertDirty(uri, false);
+        assertEquals(version, getVersion(uri));
+    }
+
+    public void testRawContactDeletionSetsDirty() {
+        final long rawContactId = createRawContact(mAccount);
+        Uri uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI,
+                rawContactId);
+        long version = getVersion(uri);
+        clearDirty(uri);
+        assertDirty(uri, false);
+
+        mResolver.delete(uri, null, null);
+        assertStoredValues(uri, RawContacts.DELETED, "1");
+        assertDirty(uri, true);
+        version++;
+        assertEquals(version, getVersion(uri));
+    }
+}
+
diff --git a/tests/src/com/android/providers/contacts/GlobalSearchSupportTest.java b/tests/src/com/android/providers/contacts/GlobalSearchSupportTest.java
new file mode 100644
index 0000000..8921d41
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/GlobalSearchSupportTest.java
@@ -0,0 +1,245 @@
+/*
+ * 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.app.SearchManager;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.Contacts.Intents;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Presence;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.test.suitebuilder.annotation.LargeTest;
+
+import java.io.IOException;
+
+/**
+ * Unit tests for {@link GlobalSearchSupport}.
+ *
+ * Run the test like this:
+ * <code>
+ * adb shell am instrument -w \
+ *         com.android.providers.contacts.tests/android.test.InstrumentationTestRunner
+ * </code>
+ */
+@LargeTest
+public class GlobalSearchSupportTest extends BaseContactsProvider2Test {
+    public void testSearchSuggestionsNotInVisibleGroup() throws Exception {
+        long rawContactId = createRawContact();
+        insertStructuredName(rawContactId, "Deer", "Dough");
+
+        Uri searchUri = new Uri.Builder().scheme("content").authority(ContactsContract.AUTHORITY)
+                .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY).appendPath("D").build();
+
+        // If the contact is not in the "my contacts" group, nothing should be found
+        Cursor c = mResolver.query(searchUri, null, null, null, null);
+        assertEquals(0, c.getCount());
+        c.close();
+    }
+
+    public void testSearchSuggestionsByName() throws Exception {
+        long groupId = createGroup(mAccount, "gsid1", "title1");
+
+        assertSearchSuggestion(groupId,
+                true,  // name
+                true,  // photo
+                false, // organization
+                false, // phone
+                false, // email
+                "D",   // query
+                true,  // expect icon URI
+                null, "Deer Dough", null);
+
+        assertSearchSuggestion(groupId,
+                true,  // name
+                true,  // photo
+                true,  // organization
+                false, // phone
+                false, // email
+                "D",   // query
+                true,  // expect icon URI
+                null, "Deer Dough", "Google");
+
+        assertSearchSuggestion(groupId,
+                true,  // name
+                true,  // photo
+                false, // organization
+                true,  // phone
+                false, // email
+                "D",   // query
+                true,  // expect icon URI
+                null, "Deer Dough", "1-800-4664-411");
+
+        assertSearchSuggestion(groupId,
+                true,  // name
+                true,  // photo
+                false, // organization
+                false, // phone
+                true,  // email
+                "D",   // query
+                true,  // expect icon URI
+                String.valueOf(Presence.getPresenceIconResourceId(Presence.OFFLINE)),
+                "Deer Dough", "foo@acme.com");
+
+        assertSearchSuggestion(groupId,
+                true,  // name
+                false, // photo
+                true,  // organization
+                false, // phone
+                false, // email
+                "D",   // query
+                false, // expect icon URI
+                null, "Deer Dough", "Google");
+    }
+
+    private void assertSearchSuggestion(long groupId, boolean name, boolean photo,
+            boolean organization, boolean phone, boolean email, String query,
+            boolean expectIcon1Uri, String expectedIcon2, String expectedText1, String expectedText2)
+            throws IOException {
+        ContentValues values = new ContentValues();
+
+        long rawContactId = createRawContact();
+        insertGroupMembership(rawContactId, groupId);
+
+        if (name) {
+            insertStructuredName(rawContactId, "Deer", "Dough");
+        }
+
+        final Uri rawContactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId);
+        if (photo) {
+            values.clear();
+            byte[] photoData = loadTestPhoto();
+            values.put(Data.RAW_CONTACT_ID, rawContactId);
+            values.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE);
+            values.put(Photo.PHOTO, photoData);
+            mResolver.insert(Data.CONTENT_URI, values);
+        }
+
+        if (organization) {
+            values.clear();
+            values.put(Data.RAW_CONTACT_ID, rawContactId);
+            values.put(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE);
+            values.put(Organization.TYPE, Organization.TYPE_WORK);
+            values.put(Organization.COMPANY, "Google");
+            mResolver.insert(Data.CONTENT_URI, values);
+        }
+
+        if (email) {
+            values.clear();
+            values.put(Data.RAW_CONTACT_ID, rawContactId);
+            values.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+            values.put(Email.TYPE, Email.TYPE_WORK);
+            values.put(Email.DATA, "foo@acme.com");
+            mResolver.insert(Data.CONTENT_URI, values);
+
+            int protocol = Im.PROTOCOL_GOOGLE_TALK;
+
+            values.clear();
+            values.put(Presence.IM_PROTOCOL, protocol);
+            values.put(Presence.IM_HANDLE, "foo@acme.com");
+            values.put(Presence.IM_ACCOUNT, "foo");
+            values.put(Presence.PRESENCE_STATUS, Presence.OFFLINE);
+            values.put(Presence.PRESENCE_CUSTOM_STATUS, "Coding for Android");
+            mResolver.insert(Presence.CONTENT_URI, values);
+        }
+
+        if (phone) {
+            values.clear();
+            values.put(Data.RAW_CONTACT_ID, rawContactId);
+            values.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+            values.put(Data.IS_PRIMARY, 1);
+            values.put(Phone.TYPE, Phone.TYPE_HOME);
+            values.put(Phone.NUMBER, "1-800-4664-411");
+            mResolver.insert(Data.CONTENT_URI, values);
+        }
+
+        long contactId = queryContactId(rawContactId);
+        Uri searchUri = new Uri.Builder().scheme("content").authority(ContactsContract.AUTHORITY)
+                .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY).appendPath(query).build();
+
+        Cursor c = mResolver.query(searchUri, null, null, null, null);
+        assertEquals(1, c.getCount());
+        c.moveToFirst();
+        values.clear();
+
+        // SearchManager does not declare a constant for _id
+        values.put("_id", contactId);
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_1, expectedText1);
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_2, expectedText2);
+
+        String icon1 = c.getString(c.getColumnIndex(SearchManager.SUGGEST_COLUMN_ICON_1));
+        if (expectIcon1Uri) {
+            assertTrue(icon1.startsWith("content:"));
+        } else {
+            assertEquals(String.valueOf(com.android.internal.R.drawable.ic_contact_picture), icon1);
+        }
+
+        values.put(SearchManager.SUGGEST_COLUMN_ICON_2, expectedIcon2);
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID, contactId);
+        values.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, contactId);
+        assertCursorValues(c, values);
+        c.close();
+
+        // Cleanup
+        mResolver.delete(rawContactUri, null, null);
+    }
+
+    public void testSearchSuggestionsByPhoneNumber() throws Exception {
+        ContentValues values = new ContentValues();
+
+        Uri searchUri = new Uri.Builder().scheme("content").authority(ContactsContract.AUTHORITY)
+                .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY).appendPath("12345").build();
+
+        Cursor c = mResolver.query(searchUri, null, null, null, null);
+        DatabaseUtils.dumpCursor(c);
+        assertEquals(2, c.getCount());
+        c.moveToFirst();
+
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_1, "Dial number");
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_2, "using 12345");
+        values.put(SearchManager.SUGGEST_COLUMN_ICON_1,
+                String.valueOf(com.android.internal.R.drawable.call_contact));
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
+                Intents.SEARCH_SUGGESTION_DIAL_NUMBER_CLICKED);
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA, "tel:12345");
+        values.putNull(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID);
+        assertCursorValues(c, values);
+
+        c.moveToNext();
+        values.clear();
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_1, "Create contact");
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_2, "using 12345");
+        values.put(SearchManager.SUGGEST_COLUMN_ICON_1,
+                String.valueOf(com.android.internal.R.drawable.create_contact));
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
+                Intents.SEARCH_SUGGESTION_CREATE_CONTACT_CLICKED);
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA, "tel:12345");
+        values.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
+                SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT);
+        assertCursorValues(c, values);
+        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..38715b5
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/GroupsTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract.Groups;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+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 BaseContactsProvider2Test {
+
+    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
+        // TODO make the provider wipe data automatically
+        ((SynchronousContactsProvider2)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.getContactForRawContact(contactCharlie);
+            long aggCharlieDupe = mActor.getContactForRawContact(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");
+            }
+        }
+
+    }
+
+    public void testGroupDirtySetOnChange() {
+        Uri uri = ContentUris.withAppendedId(Groups.CONTENT_URI,
+                createGroup(mAccount, "gsid1", "title1"));
+        assertDirty(uri, true);
+        clearDirty(uri);
+        assertDirty(uri, false);
+    }
+
+    public void testGroupDirtyClearedWhenSetExplicitly() {
+        Uri uri = ContentUris.withAppendedId(Groups.CONTENT_URI,
+                createGroup(mAccount, "gsid1", "title1"));
+        assertDirty(uri, true);
+
+        ContentValues values = new ContentValues();
+        values.put(Groups.DIRTY, 0);
+        values.put(Groups.NOTES, "other notes");
+        assertEquals(1, mResolver.update(uri, values, null, null));
+
+        assertDirty(uri, false);
+    }
+
+    public void testGroupVersionUpdates() {
+        Uri uri = ContentUris.withAppendedId(Groups.CONTENT_URI,
+                createGroup(mAccount, "gsid1", "title1"));
+        long version = getVersion(uri);
+        ContentValues values = new ContentValues();
+        values.put(Groups.TITLE, "title2");
+        mResolver.update(uri, values, null, null);
+        assertEquals(version + 1, getVersion(uri));
+    }
+
+    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/LegacyContactsProviderTest.java b/tests/src/com/android/providers/contacts/LegacyContactsProviderTest.java
new file mode 100644
index 0000000..870c7d2
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/LegacyContactsProviderTest.java
@@ -0,0 +1,849 @@
+/*
+ * 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.app.SearchManager;
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.Contacts;
+import android.provider.Contacts.ContactMethods;
+import android.provider.Contacts.Extensions;
+import android.provider.Contacts.GroupMembership;
+import android.provider.Contacts.Groups;
+import android.provider.Contacts.Intents;
+import android.provider.Contacts.Organizations;
+import android.provider.Contacts.People;
+import android.provider.Contacts.Phones;
+import android.provider.Contacts.Photos;
+import android.provider.Contacts.Presence;
+import android.test.suitebuilder.annotation.LargeTest;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Tests for legacy contacts APIs.
+ */
+@LargeTest
+public class LegacyContactsProviderTest extends BaseContactsProvider2Test {
+
+    private static final boolean USE_LEGACY_PROVIDER = false;
+
+    @Override
+    protected Class<? extends ContentProvider> getProviderClass() {
+       return USE_LEGACY_PROVIDER ? ContactsProvider.class : SynchronousContactsProvider2.class;
+    }
+
+    @Override
+    protected String getAuthority() {
+        return Contacts.AUTHORITY;
+    }
+
+    public void testPeopleInsert() {
+        ContentValues values = new ContentValues();
+        putContactValues(values);
+
+        Uri uri = mResolver.insert(People.CONTENT_URI, values);
+        assertStoredValues(uri, values);
+        assertSelection(People.CONTENT_URI, values, People._ID, ContentUris.parseId(uri));
+    }
+
+    public void testPeopleDelete() {
+        ContentValues values = new ContentValues();
+        values.put(People.NAME, "John Doe");
+        Uri personId = mResolver.insert(People.CONTENT_URI, values);
+        mResolver.delete(personId, null, null);
+
+        Cursor c = mResolver.query(personId, null, People.NAME + "='John Doe'" , null, null);
+        assertEquals("Record count after deletion", 0, c.getCount());
+        c.close();
+
+        try {
+            mResolver.query(People.DELETED_CONTENT_URI, null, null, null, null);
+        } catch (UnsupportedOperationException e) {
+            // Expected exception
+        }
+    }
+
+    public void testPeopleFilter() {
+        ContentValues values = new ContentValues();
+        values.put(People.NAME, "Deer Doe");
+        mResolver.insert(People.CONTENT_URI, values);
+
+        values.clear();
+        values.put(People.NAME, "Dear Dough");
+        mResolver.insert(People.CONTENT_URI, values);
+
+        values.clear();
+        values.put(People.NAME, "D.R. Dauwe");
+        mResolver.insert(People.CONTENT_URI, values);
+
+        assertFilteredContacts("d", "Deer Doe", "Dear Dough", "D.R. Dauwe");
+        assertFilteredContacts("de", "Deer Doe", "Dear Dough");
+        assertFilteredContacts("dee", "Deer Doe");
+        assertFilteredContacts("der");
+    }
+
+    public void testDefaultDisplayName() {
+        ContentValues values = new ContentValues();
+        values.put(People.NAME, "John Doe");
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        assertStoredValues(personUri, People.DISPLAY_NAME, "John Doe");
+    }
+
+    public void testPrimaryOrganization() {
+        ContentValues values = new ContentValues();
+        final Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        // Primary
+        values.clear();
+        values.put(Organizations.ISPRIMARY, 1);
+        values.put(Organizations.COMPANY, "Google");
+        values.put(Organizations.TYPE, Organizations.TYPE_WORK);
+        values.put(Organizations.PERSON_ID, personId);
+        Uri orgUri1 = mResolver.insert(Organizations.CONTENT_URI, values);
+
+        // Non-primary
+        values.clear();
+        values.put(Organizations.COMPANY, "Acme");
+        values.put(Organizations.TYPE, Organizations.TYPE_WORK);
+        values.put(Organizations.PERSON_ID, personId);
+        Uri orgUri2 = mResolver.insert(Organizations.CONTENT_URI, values);
+
+        values.clear();
+        values.put(People.PRIMARY_ORGANIZATION_ID, ContentUris.parseId(orgUri1));
+        values.put(People.DISPLAY_NAME, "Google");
+        assertStoredValues(personUri, values);
+
+        // Remove the original primary organization
+        mResolver.delete(orgUri1, null, null);
+
+        values.clear();
+        values.put(People.PRIMARY_ORGANIZATION_ID, ContentUris.parseId(orgUri2));
+        values.put(People.DISPLAY_NAME, "Acme");
+        assertStoredValues(personUri, values);
+
+        // Remove the remaining organization
+        mResolver.delete(orgUri2, null, null);
+
+        values.clear();
+        values.putNull(People.PRIMARY_ORGANIZATION_ID);
+        values.putNull(People.DISPLAY_NAME);
+        assertStoredValues(personUri, values);
+    }
+
+    public void testPrimaryPhone() {
+        ContentValues values = new ContentValues();
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        // Primary
+        values.clear();
+        values.put(Phones.ISPRIMARY, 1);
+        values.put(Phones.TYPE, Phones.TYPE_WORK);
+        values.put(Phones.PERSON_ID, personId);
+        values.put(Phones.NUMBER, "12345");
+        Uri phoneUri1 = mResolver.insert(Phones.CONTENT_URI, values);
+
+        // Non-primary
+        values.clear();
+        values.put(Phones.TYPE, Phones.TYPE_WORK);
+        values.put(Phones.PERSON_ID, personId);
+        values.put(Phones.NUMBER, "67890");
+        Uri phoneUri2 = mResolver.insert(Phones.CONTENT_URI, values);
+
+        values.clear();
+        values.put(People.PRIMARY_PHONE_ID, ContentUris.parseId(phoneUri1));
+        values.put(People.DISPLAY_NAME, "12345");
+        assertStoredValues(personUri, values);
+
+        // Remove the primary phone number
+        mResolver.delete(phoneUri1, null, null);
+
+        values.clear();
+        values.put(People.PRIMARY_PHONE_ID, ContentUris.parseId(phoneUri2));
+        values.put(People.DISPLAY_NAME, "67890");
+        assertStoredValues(personUri, values);
+
+        // Remove the remaining phone number
+        mResolver.delete(phoneUri2, null, null);
+
+        values.clear();
+        values.putNull(People.PRIMARY_PHONE_ID);
+        values.putNull(People.DISPLAY_NAME);
+        assertStoredValues(personUri, values);
+    }
+
+    public void testPrimaryEmail() {
+        ContentValues values = new ContentValues();
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        // Primary
+        values.clear();
+        values.put(ContactMethods.PERSON_ID, personId);
+        values.put(ContactMethods.KIND, Contacts.KIND_EMAIL);
+        values.put(ContactMethods.TYPE, ContactMethods.TYPE_HOME);
+        values.put(ContactMethods.DATA, "foo@acme.com");
+        values.put(ContactMethods.ISPRIMARY, 1);
+        Uri emailUri1 = mResolver.insert(ContactMethods.CONTENT_URI, values);
+
+        // Non-primary
+        values.clear();
+        values.put(ContactMethods.PERSON_ID, personId);
+        values.put(ContactMethods.KIND, Contacts.KIND_EMAIL);
+        values.put(ContactMethods.TYPE, ContactMethods.TYPE_WORK);
+        values.put(ContactMethods.DATA, "bar@acme.com");
+        Uri emailUri2 = mResolver.insert(ContactMethods.CONTENT_URI, values);
+
+        values.clear();
+        values.put(People.PRIMARY_EMAIL_ID, ContentUris.parseId(emailUri1));
+        values.put(People.DISPLAY_NAME, "foo@acme.com");
+        assertStoredValues(personUri, values);
+
+        // Remove the primary email
+        mResolver.delete(emailUri1, null, null);
+
+        values.clear();
+        values.put(People.PRIMARY_EMAIL_ID, ContentUris.parseId(emailUri2));
+        values.put(People.DISPLAY_NAME, "bar@acme.com");
+        assertStoredValues(personUri, values);
+
+        // Remove the remaining email
+        mResolver.delete(emailUri2, null, null);
+
+        values.clear();
+        values.putNull(People.PRIMARY_EMAIL_ID);
+        values.putNull(People.DISPLAY_NAME);
+        assertStoredValues(personUri, values);
+    }
+
+    public void testMarkAsContacted() {
+        ContentValues values = new ContentValues();
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        int timesContactedBefore =
+                Integer.parseInt(getStoredValue(personUri, People.TIMES_CONTACTED));
+        long timeBefore = System.currentTimeMillis();
+        People.markAsContacted(mResolver, personId);
+        long timeAfter = System.currentTimeMillis();
+
+        long lastContacted = Long.parseLong(getStoredValue(personUri, People.LAST_TIME_CONTACTED));
+        int timesContactedAfter =
+            Integer.parseInt(getStoredValue(personUri, People.TIMES_CONTACTED));
+
+        assertTrue(lastContacted >= timeBefore);
+        assertTrue(lastContacted <= timeAfter);
+        assertEquals(timesContactedAfter, timesContactedBefore + 1);
+    }
+
+    public void testOrganizationsInsert() {
+        ContentValues values = new ContentValues();
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        values.clear();
+        values.put(Organizations.COMPANY, "Sierra");
+        values.put(Organizations.PERSON_ID, personId);
+        values.put(Organizations.TYPE, Organizations.TYPE_CUSTOM);
+        values.put(Organizations.LABEL, "Club");
+        values.put(Organizations.TITLE, "Member");
+        values.put(Organizations.ISPRIMARY, 1);
+
+        Uri uri = mResolver.insert(Organizations.CONTENT_URI, values);
+        assertStoredValues(uri, values);
+        assertSelection(Organizations.CONTENT_URI, values,
+                Organizations._ID, ContentUris.parseId(uri));
+
+        assertPersonIdConstraint(Organizations.CONTENT_URI, Organizations.TYPE,
+                Organizations.TYPE_WORK);
+
+        assertTypeAndLabelConstraints(Organizations.CONTENT_URI, Organizations.PERSON_ID, personId,
+                Organizations.TYPE, Organizations.TYPE_CUSTOM, Organizations.TYPE_OTHER,
+                Organizations.LABEL);
+    }
+
+    public void testPhonesInsert() {
+        ContentValues values = new ContentValues();
+        putContactValues(values);
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        values.clear();
+        values.put(Phones.PERSON_ID, personId);
+        values.put(Phones.TYPE, Phones.TYPE_CUSTOM);
+        values.put(Phones.LABEL, "Directory");
+        values.put(Phones.NUMBER, "1-800-4664-411");
+        values.put(Phones.ISPRIMARY, 1);
+
+        Uri uri = mResolver.insert(Phones.CONTENT_URI, values);
+
+        // Adding another value to assert
+        values.put(Phones.NUMBER_KEY, "11446640081");
+
+        // The result is joined with People
+        putContactValues(values);
+        assertStoredValues(uri, values);
+        assertSelection(Phones.CONTENT_URI, values,
+                Phones._ID, ContentUris.parseId(uri));
+
+        // Access the phone through People
+        Uri twigUri = Uri.withAppendedPath(personUri, People.Phones.CONTENT_DIRECTORY);
+        assertStoredValues(twigUri, values);
+
+        // Now the person should be joined with Phone
+        values.clear();
+        putContactValues(values);
+        values.put(People.TYPE, Phones.TYPE_CUSTOM);
+        values.put(People.LABEL, "Directory");
+        values.put(People.NUMBER, "1-800-4664-411");
+        assertStoredValues(personUri, values);
+
+        assertPersonIdConstraint(Phones.CONTENT_URI, Phones.TYPE, Phones.TYPE_WORK);
+
+        assertTypeAndLabelConstraints(Phones.CONTENT_URI, Phones.PERSON_ID, personId, Phones.TYPE,
+                Phones.TYPE_CUSTOM, Phones.TYPE_OTHER, Phones.LABEL);
+    }
+
+    public void testEmailInsert() {
+        assertContactMethodInsert(Contacts.KIND_EMAIL, ContactMethods.TYPE_CUSTOM,
+                "Some other way", "foo@acme.com", null, true);
+    }
+
+    public void testImInsert() {
+        assertContactMethodInsert(Contacts.KIND_IM, ContactMethods.TYPE_CUSTOM, "Some other way",
+                "Foo", "Bar", true);
+    }
+
+    public void testPostalInsert() {
+        assertContactMethodInsert(Contacts.KIND_POSTAL, ContactMethods.TYPE_CUSTOM,
+                "Some other way", "Foo", "Bar", true);
+    }
+
+    private void assertContactMethodInsert(int kind, int type, String label, String data,
+            String auxData, boolean primary) {
+        ContentValues values = new ContentValues();
+        putContactValues(values);
+        final Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        values.clear();
+        values.put(ContactMethods.PERSON_ID, personId);
+        values.put(ContactMethods.KIND, kind);
+        values.put(ContactMethods.TYPE, type);
+        values.put(ContactMethods.LABEL, label);
+        values.put(ContactMethods.DATA, data);
+        values.put(ContactMethods.AUX_DATA, auxData);
+        values.put(ContactMethods.ISPRIMARY, primary ? 1 : 0);
+
+        Uri uri = mResolver.insert(ContactMethods.CONTENT_URI, values);
+
+        // The result is joined with People
+        putContactValues(values);
+        assertStoredValues(uri, values);
+        assertSelection(ContactMethods.CONTENT_URI, values,
+                ContactMethods._ID, ContentUris.parseId(uri));
+
+        // Access the contact method through People
+        Uri twigUri = Uri.withAppendedPath(personUri, People.ContactMethods.CONTENT_DIRECTORY);
+        assertStoredValues(twigUri, values);
+
+        assertPersonIdConstraint(ContactMethods.CONTENT_URI, ContactMethods.TYPE,
+                ContactMethods.TYPE_WORK);
+
+        assertTypeAndLabelConstraints(ContactMethods.CONTENT_URI, ContactMethods.PERSON_ID,
+                personId, ContactMethods.TYPE, ContactMethods.TYPE_CUSTOM,
+                ContactMethods.TYPE_OTHER, ContactMethods.LABEL);
+    }
+
+    public void testExtensionsInsert() {
+        ContentValues values = new ContentValues();
+        final Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        values.clear();
+        values.put(Extensions.PERSON_ID, personId);
+        values.put(Extensions.NAME, "Foo");
+        values.put(Extensions.VALUE, "Bar");
+
+        Uri uri = mResolver.insert(Extensions.CONTENT_URI, values);
+        assertStoredValues(uri, values);
+        assertSelection(Extensions.CONTENT_URI, values,
+                Extensions._ID, ContentUris.parseId(uri));
+
+        // Access the extensions through People
+        Uri twigUri = Uri.withAppendedPath(personUri, People.Extensions.CONTENT_DIRECTORY);
+        assertStoredValues(twigUri, values);
+    }
+
+    public void testGroupsInsert() {
+        ContentValues values = new ContentValues();
+        values.put(Groups.NAME, "Galois");
+        values.put(Groups.NOTES, "Abel");
+        values.put(Groups.SYSTEM_ID, "12345");
+
+        Uri groupUri = mResolver.insert(Groups.CONTENT_URI, values);
+        assertStoredValues(groupUri, values);
+    }
+
+    public void testGroupMembershipsInsert() {
+        ContentValues values = new ContentValues();
+        values.put(Groups.NAME, "Galois");
+        values.put(Groups.NOTES, "Abel");
+        Uri groupUri = mResolver.insert(Groups.CONTENT_URI, values);
+
+        values.clear();
+        values.put(People.NAME, "Klein");
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+
+        long groupId = ContentUris.parseId(groupUri);
+        long personId = ContentUris.parseId(personUri);
+
+        values.clear();
+        values.put(GroupMembership.GROUP_ID, groupId);
+        values.put(GroupMembership.PERSON_ID, personId);
+        Uri membershipUri = mResolver.insert(GroupMembership.CONTENT_URI, values);
+        assertStoredValues(membershipUri, values);
+        assertSelection(GroupMembership.CONTENT_URI, values,
+                GroupMembership._ID, ContentUris.parseId(membershipUri));
+
+        Uri personsGroupsUri = Uri.withAppendedPath(personUri, GroupMembership.CONTENT_DIRECTORY);
+        assertStoredValues(personsGroupsUri, values);
+    }
+
+    public void testAddToGroup() {
+        ContentValues values = new ContentValues();
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        values.clear();
+        values.put(Groups.NAME, "Galois");
+        Uri groupUri = mResolver.insert(Groups.CONTENT_URI, values);
+
+        People.addToGroup(mResolver, personId, "Galois");
+
+        values.clear();
+        values.put(GroupMembership.GROUP_ID, ContentUris.parseId(groupUri));
+        values.put(GroupMembership.PERSON_ID, personId);
+
+        Uri personsGroupsUri = Uri.withAppendedPath(personUri, GroupMembership.CONTENT_DIRECTORY);
+        assertStoredValues(personsGroupsUri, values);
+    }
+
+    public void testPresenceInsertMatchOnHandle() {
+        ContentValues values = new ContentValues();
+        putContactValues(values);
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        String encodedProtocol =
+                ContactMethods.encodePredefinedImProtocol(ContactMethods.PROTOCOL_GOOGLE_TALK);
+
+        values.clear();
+        values.put(ContactMethods.PERSON_ID, personId);
+        values.put(ContactMethods.KIND, Contacts.KIND_IM);
+        values.put(ContactMethods.TYPE, ContactMethods.TYPE_HOME);
+        values.put(ContactMethods.DATA, "Android");
+        values.put(ContactMethods.AUX_DATA, encodedProtocol);
+        mResolver.insert(ContactMethods.CONTENT_URI, values);
+
+        values.clear();
+        values.put(Presence.IM_PROTOCOL, encodedProtocol);
+        values.put(Presence.IM_HANDLE, "Android");
+        values.put(Presence.IM_ACCOUNT, "foo");
+        values.put(Presence.PRESENCE_STATUS, Presence.OFFLINE);
+        values.put(Presence.PRESENCE_CUSTOM_STATUS, "Coding for Android");
+
+        Uri presenceUri = mResolver.insert(Presence.CONTENT_URI, values);
+        assertSelection(Presence.CONTENT_URI, values,
+                Presence._ID, ContentUris.parseId(presenceUri));
+
+        values.put(Presence.PERSON_ID, personId);
+        assertStoredValues(presenceUri, values);
+
+        // Now the person should be joined with Presence
+        values.clear();
+        putContactValues(values);
+        values.put(People.IM_PROTOCOL, encodedProtocol);
+        values.put(People.IM_HANDLE, "Android");
+        values.put(People.IM_ACCOUNT, "foo");
+        values.put(People.PRESENCE_STATUS, Presence.OFFLINE);
+        values.put(People.PRESENCE_CUSTOM_STATUS, "Coding for Android");
+        assertStoredValues(personUri, values);
+    }
+
+    public void testPresenceInsertMatchOnEmail() {
+        ContentValues values = new ContentValues();
+        putContactValues(values);
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        String protocol =
+            ContactMethods.encodePredefinedImProtocol(ContactMethods.PROTOCOL_GOOGLE_TALK);
+
+        values.clear();
+        values.put(ContactMethods.PERSON_ID, personId);
+        values.put(ContactMethods.KIND, Contacts.KIND_EMAIL);
+        values.put(ContactMethods.TYPE, ContactMethods.TYPE_HOME);
+        values.put(ContactMethods.DATA, "Android@android.com");
+        mResolver.insert(ContactMethods.CONTENT_URI, values);
+
+        values.clear();
+        values.put(Presence.IM_PROTOCOL, protocol);
+        values.put(Presence.IM_HANDLE, "Android@android.com");
+        values.put(Presence.IM_ACCOUNT, "foo");
+        values.put(Presence.PRESENCE_STATUS, Presence.OFFLINE);
+        values.put(Presence.PRESENCE_CUSTOM_STATUS, "Coding for Android");
+
+        Uri presenceUri = mResolver.insert(Presence.CONTENT_URI, values);
+
+        // FIXME person_id was not available in legacy ContactsProvider
+        // values.put(Presence.PERSON_ID, personId);
+        assertStoredValues(presenceUri, values);
+        assertSelection(Presence.CONTENT_URI, values,
+                Presence._ID, ContentUris.parseId(presenceUri));
+
+        // Now the person should be joined with Presence
+        values.clear();
+        putContactValues(values);
+        values.put(People.IM_PROTOCOL, protocol);
+        values.put(People.IM_HANDLE, "Android@android.com");
+        values.put(People.IM_ACCOUNT, "foo");
+        values.put(People.PRESENCE_STATUS, Presence.OFFLINE);
+        values.put(People.PRESENCE_CUSTOM_STATUS, "Coding for Android");
+        assertStoredValues(personUri, values);
+    }
+
+    public void testPhotoUpdate() throws Exception {
+        byte[] photo = loadTestPhoto();
+
+        ContentValues values = new ContentValues();
+        Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+
+        values.clear();
+        values.put(Photos.DATA, photo);
+        values.put(Photos.LOCAL_VERSION, "10");
+        // FIXME this column was unavailable for update in legacy ContactsProvider
+        // values.put(Photos.DOWNLOAD_REQUIRED, 1);
+        values.put(Photos.EXISTS_ON_SERVER, 1);
+        values.put(Photos.SYNC_ERROR, "404 does not exist");
+
+        Uri photoUri = Uri.withAppendedPath(personUri, Photos.CONTENT_DIRECTORY);
+        mResolver.update(photoUri, values, null, null);
+        assertStoredValues(photoUri, values);
+    }
+
+    /**
+     * Capturing the search suggestion requirements in test cases as a reference.
+     */
+    public void testSearchSuggestionsNotInMyContacts() throws Exception {
+
+        // We don't provide compatibility for search suggestions
+        if (!USE_LEGACY_PROVIDER) {
+            return;
+        }
+
+        ContentValues values = new ContentValues();
+        putContactValues(values);
+        mResolver.insert(People.CONTENT_URI, values);
+
+        Uri searchUri = new Uri.Builder().scheme("content").authority(Contacts.AUTHORITY)
+                .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY).appendPath("D").build();
+
+        // If the contact is not in the "my contacts" group, nothing should be found
+        Cursor c = mResolver.query(searchUri, null, null, null, null);
+        assertEquals(0, c.getCount());
+        c.close();
+    }
+
+    /**
+     * Capturing the search suggestion requirements in test cases as a reference.
+     */
+    public void testSearchSuggestionsByName() throws Exception {
+
+        // We don't provide compatibility for search suggestions
+        if (!USE_LEGACY_PROVIDER) {
+            return;
+        }
+
+        assertSearchSuggestion(
+                true,  // name
+                true,  // photo
+                true,  // organization
+                false, // phone
+                false, // email
+                "D",   // query
+                true,  // expect icon URI
+                null, "Deer Dough", "Google");
+
+        assertSearchSuggestion(
+                true,  // name
+                true,  // photo
+                false, // organization
+                true,  // phone
+                false, // email
+                "D",   // query
+                true,  // expect icon URI
+                null, "Deer Dough", "1-800-4664-411");
+
+        assertSearchSuggestion(
+                true,  // name
+                true,  // photo
+                false, // organization
+                false, // phone
+                true,  // email
+                "D",   // query
+                true,  // expect icon URI
+                String.valueOf(Presence.getPresenceIconResourceId(Presence.OFFLINE)),
+                "Deer Dough", "foo@acme.com");
+
+        assertSearchSuggestion(
+                true,  // name
+                false, // photo
+                true,  // organization
+                false, // phone
+                false, // email
+                "D",   // query
+                false, // expect icon URI
+                null, "Deer Dough", "Google");
+    }
+
+    private void assertSearchSuggestion(boolean name, boolean photo, boolean organization,
+            boolean phone, boolean email, String query, boolean expectIcon1Uri, String expectedIcon2,
+            String expectedText1, String expectedText2) throws IOException {
+        ContentValues values = new ContentValues();
+
+        if (name) {
+            values.put(People.NAME, "Deer Dough");
+        }
+
+        final Uri personUri = mResolver.insert(People.CONTENT_URI, values);
+        long personId = ContentUris.parseId(personUri);
+
+        People.addToMyContactsGroup(mResolver, personId);
+
+        if (photo) {
+            values.clear();
+            byte[] photoData = loadTestPhoto();
+            values.put(Photos.DATA, photoData);
+            values.put(Photos.LOCAL_VERSION, "1");
+            values.put(Photos.EXISTS_ON_SERVER, 0);
+            Uri photoUri = Uri.withAppendedPath(personUri, Photos.CONTENT_DIRECTORY);
+            mResolver.update(photoUri, values, null, null);
+        }
+
+        if (organization) {
+            values.clear();
+            values.put(Organizations.ISPRIMARY, 1);
+            values.put(Organizations.COMPANY, "Google");
+            values.put(Organizations.TYPE, Organizations.TYPE_WORK);
+            values.put(Organizations.PERSON_ID, personId);
+            mResolver.insert(Organizations.CONTENT_URI, values);
+        }
+
+        if (email) {
+            values.clear();
+            values.put(ContactMethods.PERSON_ID, personId);
+            values.put(ContactMethods.KIND, Contacts.KIND_EMAIL);
+            values.put(ContactMethods.TYPE, ContactMethods.TYPE_HOME);
+            values.put(ContactMethods.DATA, "foo@acme.com");
+            values.put(ContactMethods.ISPRIMARY, 1);
+            mResolver.insert(ContactMethods.CONTENT_URI, values);
+
+
+            String protocol = ContactMethods
+                    .encodePredefinedImProtocol(ContactMethods.PROTOCOL_GOOGLE_TALK);
+            values.clear();
+            values.put(Presence.IM_PROTOCOL, protocol);
+            values.put(Presence.IM_HANDLE, "foo@acme.com");
+            values.put(Presence.IM_ACCOUNT, "foo");
+            values.put(Presence.PRESENCE_STATUS, Presence.OFFLINE);
+            values.put(Presence.PRESENCE_CUSTOM_STATUS, "Coding for Android");
+            mResolver.insert(Presence.CONTENT_URI, values);
+        }
+
+        if (phone) {
+            values.clear();
+            values.put(Phones.PERSON_ID, personId);
+            values.put(Phones.TYPE, Phones.TYPE_HOME);
+            values.put(Phones.NUMBER, "1-800-4664-411");
+            values.put(Phones.ISPRIMARY, 1);
+            mResolver.insert(Phones.CONTENT_URI, values);
+        }
+
+        Uri searchUri = new Uri.Builder().scheme("content").authority(Contacts.AUTHORITY)
+                .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY).appendPath(query).build();
+
+        Cursor c = mResolver.query(searchUri, null, null, null, null);
+        assertEquals(1, c.getCount());
+        c.moveToFirst();
+        values.clear();
+
+        String icon1 = c.getString(c.getColumnIndex(SearchManager.SUGGEST_COLUMN_ICON_1));
+        if (expectIcon1Uri) {
+            assertTrue(icon1.startsWith("content:"));
+        } else {
+            assertEquals(String.valueOf(com.android.internal.R.drawable.ic_contact_picture), icon1);
+        }
+
+        // SearchManager does not declare a constant for _id
+        values.put("_id", personId);
+        values.put(SearchManager.SUGGEST_COLUMN_ICON_2, expectedIcon2);
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID, personId);
+        values.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, personId);
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_1, expectedText1);
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_2, expectedText2);
+        assertCursorValues(c, values);
+        c.close();
+
+        // Cleanup
+        mResolver.delete(personUri, null, null);
+    }
+
+    /**
+     * Capturing the search suggestion requirements in test cases as a reference.
+     */
+    public void testSearchSuggestionsByPhoneNumber() throws Exception {
+
+        // We don't provide compatibility for search suggestions
+        if (!USE_LEGACY_PROVIDER) {
+            return;
+        }
+
+        ContentValues values = new ContentValues();
+
+        Uri searchUri = new Uri.Builder().scheme("content").authority(Contacts.AUTHORITY)
+                .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY).appendPath("12345").build();
+
+        Cursor c = mResolver.query(searchUri, null, null, null, null);
+        assertEquals(2, c.getCount());
+        c.moveToFirst();
+
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_1, "Execute");
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_2, "");
+        values.put(SearchManager.SUGGEST_COLUMN_ICON_1,
+                String.valueOf(com.android.internal.R.drawable.call_contact));
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
+                Intents.SEARCH_SUGGESTION_DIAL_NUMBER_CLICKED);
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA, "tel:12345");
+        values.putNull(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID);
+        assertCursorValues(c, values);
+
+        c.moveToNext();
+        values.clear();
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_1, "Dial number");
+        values.put(SearchManager.SUGGEST_COLUMN_TEXT_2, "using 12345");
+        values.put(SearchManager.SUGGEST_COLUMN_ICON_1,
+                String.valueOf(com.android.internal.R.drawable.create_contact));
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
+                Intents.SEARCH_SUGGESTION_CREATE_CONTACT_CLICKED);
+        values.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA, "tel:12345");
+        values.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
+                SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT);
+        assertCursorValues(c, values);
+        c.close();
+    }
+
+    private void putContactValues(ContentValues values) {
+        // Populating only unhidden columns
+        values.put(People.NAME, "Deer Dough");
+        values.put(People.PHONETIC_NAME, "Dear Doe");
+        values.put(People.NOTES, "Cash Cow");
+        values.put(People.TIMES_CONTACTED, 3);
+        values.put(People.LAST_TIME_CONTACTED, 10);
+        values.put(People.CUSTOM_RINGTONE, "ringtone");
+        values.put(People.SEND_TO_VOICEMAIL, 1);
+        values.put(People.STARRED, 1);
+    }
+
+    private void assertFilteredContacts(String filter, String... expectedNames) {
+        Uri filterUri = Uri.withAppendedPath(People.CONTENT_FILTER_URI, filter);
+        Cursor c = mResolver.query(filterUri, null, null, null, null);
+        try {
+            assertEquals("Record count", expectedNames.length, c.getCount());
+            int column = c.getColumnIndex(People.NAME);
+            for (int i = 0; i < expectedNames.length; i++) {
+                c.moveToNext();
+                assertEquals(expectedNames[i], c.getString(column));
+            }
+        } finally {
+            c.close();
+        }
+    }
+
+    private void assertPersonIdConstraint(Uri uri, String typeColumn, int defaultType) {
+        ContentValues values = new ContentValues();
+        values.put(typeColumn, defaultType);
+        try {
+            mResolver.insert(uri, values);
+            fail("Inserted row without person ID");
+        } catch (Exception e) {
+            // Exception expected
+        }
+    }
+
+    private void assertTypeAndLabelConstraints(Uri uri, String personIdColumn, long personId,
+            String typeColumn, int defaultType, int otherType, String labelColumn) {
+        ContentValues values = new ContentValues();
+        values.put(personIdColumn, personId);
+        values.put(typeColumn, defaultType);
+        try {
+            mResolver.insert(uri, values);
+            fail("Inserted row with custom type but without label");
+        } catch (Exception e) {
+            // Exception expected
+        }
+
+        values.clear();
+        values.put(personIdColumn, personId);
+        try {
+            mResolver.insert(uri, values);
+            fail("Inserted row without either type or label");
+        } catch (Exception e) {
+            // Exception expected
+        }
+
+        values.clear();
+        values.put(personIdColumn, personId);
+        values.put(typeColumn, otherType);
+        values.put(labelColumn, "Foo");
+        try {
+            mResolver.insert(uri, values);
+            fail("Inserted row with both type and label");
+        } catch (Exception e) {
+            // Exception expected
+        }
+    }
+
+    @Override
+    protected void assertSelection(Uri uri, ContentValues values, String idColumn, long id) {
+        if (USE_LEGACY_PROVIDER) {
+            // A bug in the legacy ContactsProvider prevents us from using the
+            // _id column in selection.
+            super.assertSelection(uri, values, null, 0);
+        } else {
+            super.assertSelection(uri, values, idColumn, id);
+        }
+    }
+}
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..1263fe7
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/RestrictionExceptionsTest.java
@@ -0,0 +1,345 @@
+/*
+ * 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;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.Data;
+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,
+                SynchronousContactsProvider2.class, ContactsContract.AUTHORITY);
+        mRed = new ContactsActor(overallContext, PACKAGE_RED,
+                SynchronousContactsProvider2.class, ContactsContract.AUTHORITY);
+        mGreen = new ContactsActor(overallContext, PACKAGE_GREEN,
+                SynchronousContactsProvider2.class, ContactsContract.AUTHORITY);
+        mBlue = new ContactsActor(overallContext, PACKAGE_BLUE,
+                SynchronousContactsProvider2.class, ContactsContract.AUTHORITY);
+
+        // TODO make the provider wipe data automatically
+        ((SynchronousContactsProvider2)mGrey.provider).wipeData();
+    }
+
+    /**
+     * 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() {
+
+        // Grey creates an unprotected contact
+        long greyContact = mGrey.createContact(false);
+        long greyData = mGrey.createPhone(greyContact, PHONE_GREY);
+        long greyAgg = mGrey.getContactForRawContact(greyContact);
+
+        // Assert that both Grey and Blue can read contact
+        assertTrue("Owner of unrestricted contact unable to read",
+                (mGrey.getDataCountForContact(greyAgg) == 1));
+        assertTrue("Non-owner of unrestricted contact unable to read",
+                (mBlue.getDataCountForContact(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.getContactForRawContact(redContact);
+
+        // Assert that only Red can read contact
+        assertTrue("Owner of restricted contact unable to read",
+                (mRed.getDataCountForContact(redAgg) == 1));
+        assertTrue("Non-owner of restricted contact able to read",
+                (mBlue.getDataCountForContact(redAgg) == 0));
+        assertTrue("Non-owner of restricted contact able to read",
+                (mGreen.getDataCountForContact(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.getDataCountForContact(redAgg) == 1));
+        assertTrue("Non-owner with restriction exception unable to read",
+                (mBlue.getDataCountForContact(redAgg) == 1));
+        assertTrue("Non-owner of restricted contact able to read",
+                (mGreen.getDataCountForContact(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.getDataCountForContact(redAgg) == 1));
+        assertTrue("Non-owner of restricted contact able to read",
+                (mBlue.getDataCountForContact(redAgg) == 0));
+        assertTrue("Non-owner of restricted contact able to read",
+                (mGreen.getDataCountForContact(redAgg) == 0));
+
+    }
+
+    /**
+     * Create an aggregate that has multiple contacts with various levels of
+     * protected data, and ensure that {@link Contacts#CONTENT_SUMMARY_URI}
+     * details don't expose {@link Contacts#IS_RESTRICTED} data.
+     */
+    public void __testAggregateSummary() {
+
+        // 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.getContactForRawContact(redContact);
+            long blueAgg = mBlue.getContactForRawContact(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.getContactForRawContact(redContact);
+            long blueAgg = mBlue.getContactForRawContact(blueContact);
+            long greyAgg = mGrey.getContactForRawContact(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;
+
+        // 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.getContactForRawContact(greenContact);
+
+        // AGGREGATES
+        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) != greenAgg));
+        }
+        cursor.close();
+
+        // AGGREGATES_ID
+        cursor = mRed.resolver.query(ContentUris.withAppendedId(Contacts.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(
+                Contacts.CONTENT_URI, greenAgg), Contacts.Data.CONTENT_DIRECTORY),
+                Projections.PROJ_ID, null, null, null);
+        assertTrue("Discovered restricted contact", (cursor.getCount() == 0));
+        cursor.close();
+
+        // AGGREGATES_SUMMARY
+        cursor = mRed.resolver.query(Contacts.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(Contacts.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(RawContacts.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(RawContacts.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(
+                RawContacts.CONTENT_URI, greenContact), RawContacts.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();
+        }
+
+    }
+}