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