Loading java/src/com/android/inputmethod/latin/ContactsContentObserver.java +2 −2 Original line number Diff line number Diff line Loading @@ -84,10 +84,10 @@ public class ContactsContentObserver implements Runnable { boolean haveContentsChanged() { final long startTime = SystemClock.uptimeMillis(); final int contactCount = mManager.getContactCount(); if (contactCount > ContactsDictionaryConstants.MAX_CONTACT_COUNT) { if (contactCount > ContactsDictionaryConstants.MAX_CONTACTS_PROVIDER_QUERY_LIMIT) { // If there are too many contacts then return false. In this rare case it is impossible // to include all of them anyways and the cost of rebuilding the dictionary is too high. // TODO: Sort and check only the MAX_CONTACT_COUNT most recent contacts? // TODO: Sort and check only the most recent contacts? return false; } if (contactCount != mManager.getContactCountAtLastRebuild()) { Loading java/src/com/android/inputmethod/latin/ContactsDictionaryConstants.java +7 −3 Original line number Diff line number Diff line Loading @@ -26,7 +26,8 @@ public class ContactsDictionaryConstants { /** * Projections for {@link Contacts.CONTENT_URI} */ public static final String[] PROJECTION = { BaseColumns._ID, Contacts.DISPLAY_NAME }; public static final String[] PROJECTION = { BaseColumns._ID, Contacts.DISPLAY_NAME, Contacts.TIMES_CONTACTED, Contacts.LAST_TIME_CONTACTED, Contacts.IN_VISIBLE_GROUP }; public static final String[] PROJECTION_ID_ONLY = { BaseColumns._ID }; /** Loading @@ -36,13 +37,16 @@ public class ContactsDictionaryConstants { public static final int FREQUENCY_FOR_CONTACTS_BIGRAM = 90; /** * The maximum number of contacts that this dictionary supports. * Do not attempt to query contacts if there are more than this many entries. */ public static final int MAX_CONTACT_COUNT = 10000; public static final int MAX_CONTACTS_PROVIDER_QUERY_LIMIT = 10000; /** * Index of the column for 'name' in content providers: * Contacts & ContactsContract.Profile. */ public static final int NAME_INDEX = 1; public static final int TIMES_CONTACTED_INDEX = 2; public static final int LAST_TIME_CONTACTED_INDEX = 3; public static final int IN_VISIBLE_GROUP_INDEX = 4; } java/src/com/android/inputmethod/latin/ContactsManager.java +93 −6 Original line number Diff line number Diff line Loading @@ -21,11 +21,17 @@ import android.database.Cursor; import android.database.sqlite.SQLiteException; import android.net.Uri; import android.provider.ContactsContract.Contacts; import android.text.TextUtils; import android.util.Log; import com.android.inputmethod.latin.common.Constants; import com.android.inputmethod.latin.common.StringUtils; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** Loading @@ -37,6 +43,63 @@ import java.util.concurrent.atomic.AtomicInteger; public class ContactsManager { private static final String TAG = "ContactsManager"; /** * Use at most this many of the highest affinity contacts. */ public static final int MAX_CONTACT_NAMES = 200; protected static class RankedContact { public final String mName; public final long mLastContactedTime; public final int mTimesContacted; public final boolean mInVisibleGroup; private float mAffinity = 0.0f; RankedContact(final Cursor cursor) { mName = cursor.getString( ContactsDictionaryConstants.NAME_INDEX); mTimesContacted = cursor.getInt( ContactsDictionaryConstants.TIMES_CONTACTED_INDEX); mLastContactedTime = cursor.getLong( ContactsDictionaryConstants.LAST_TIME_CONTACTED_INDEX); mInVisibleGroup = cursor.getInt( ContactsDictionaryConstants.IN_VISIBLE_GROUP_INDEX) == 1; } float getAffinity() { return mAffinity; } /** * Calculates the affinity with the contact based on: * - How many times it has been contacted * - How long since the last contact. * - Whether the contact is in the visible group (i.e., Contacts list). * * Note: This affinity is limited by the fact that some apps currently do not update the * LAST_TIME_CONTACTED or TIMES_CONTACTED counters. As a result, a frequently messaged * contact may still have 0 affinity. */ void computeAffinity(final int maxTimesContacted, final long currentTime) { final float timesWeight = ((float) mTimesContacted + 1) / (maxTimesContacted + 1); final long timeSinceLastContact = Math.min( Math.max(0, currentTime - mLastContactedTime), TimeUnit.MILLISECONDS.convert(180, TimeUnit.DAYS)); final float lastTimeWeight = (float) Math.pow(0.5, timeSinceLastContact / (TimeUnit.MILLISECONDS.convert(10, TimeUnit.DAYS))); final float visibleWeight = mInVisibleGroup ? 1.0f : 0.0f; mAffinity = (timesWeight + lastTimeWeight + visibleWeight) / 3; } } private static class AffinityComparator implements Comparator<RankedContact> { @Override public int compare(RankedContact contact1, RankedContact contact2) { return Float.compare(contact2.getAffinity(), contact1.getAffinity()); } } /** * Interface to implement for classes interested in getting notified for updates * to Contacts content provider. Loading Loading @@ -82,14 +145,18 @@ public class ContactsManager { * Returns all the valid names in the Contacts DB. Callers should also * call {@link #updateLocalState(ArrayList)} after they are done with result * so that the manager can cache local state for determining updates. * * These names are sorted by their affinity to the user, with favorite * contacts appearing first. */ public ArrayList<String> getValidNames(final Uri uri) { final ArrayList<String> names = new ArrayList<>(); // Check all contacts since it's not possible to find out which names have changed. // This is needed because it's possible to receive extraneous onChange events even when no // name has changed. final Cursor cursor = mContext.getContentResolver().query(uri, ContactsDictionaryConstants.PROJECTION, null, null, null); final ArrayList<RankedContact> contacts = new ArrayList<>(); int maxTimesContacted = 0; if (cursor != null) { try { if (cursor.moveToFirst()) { Loading @@ -97,7 +164,12 @@ public class ContactsManager { final String name = cursor.getString( ContactsDictionaryConstants.NAME_INDEX); if (isValidName(name)) { names.add(name); final int timesContacted = cursor.getInt( ContactsDictionaryConstants.TIMES_CONTACTED_INDEX); if (timesContacted > maxTimesContacted) { maxTimesContacted = timesContacted; } contacts.add(new RankedContact(cursor)); } cursor.moveToNext(); } Loading @@ -106,7 +178,16 @@ public class ContactsManager { cursor.close(); } } return names; final long currentTime = System.currentTimeMillis(); for (RankedContact contact : contacts) { contact.computeAffinity(maxTimesContacted, currentTime); } Collections.sort(contacts, new AffinityComparator()); final HashSet<String> names = new HashSet<>(); for (int i = 0; i < contacts.size() && names.size() < MAX_CONTACT_NAMES; ++i) { names.add(contacts.get(i).mName); } return new ArrayList<>(names); } /** Loading Loading @@ -134,11 +215,17 @@ public class ContactsManager { } private static boolean isValidName(final String name) { if (name != null && -1 == name.indexOf(Constants.CODE_COMMERCIAL_AT)) { return true; } if (TextUtils.isEmpty(name) || name.indexOf(Constants.CODE_COMMERCIAL_AT) != -1) { return false; } final boolean hasSpace = name.indexOf(Constants.CODE_SPACE) != -1; if (!hasSpace) { // Only allow an isolated word if it does not contain a hyphen. // This helps to filter out mailing lists. return name.indexOf(Constants.CODE_DASH) == -1; } return true; } /** * Updates the local state of the manager. This should be called when the callers Loading tests/src/com/android/inputmethod/latin/ContactsManagerTest.java +50 −10 Original line number Diff line number Diff line Loading @@ -29,11 +29,15 @@ import android.test.mock.MockContentProvider; import android.test.mock.MockContentResolver; import android.test.suitebuilder.annotation.SmallTest; import com.android.inputmethod.latin.ContactsDictionaryConstants; import com.android.inputmethod.latin.ContactsManager; import org.junit.Before; import org.junit.Test; import java.util.ArrayList; import java.util.HashMap; import java.util.concurrent.TimeUnit; /** * Tests for {@link ContactsManager} Loading Loading @@ -63,12 +67,13 @@ public class ContactsManagerTest extends AndroidTestCase { @Test public void testGetValidNames() { final String contactName1 = "firstname lastname"; final String contactName1 = "firstname last-name"; final String contactName2 = "larry"; mMatrixCursor.addRow(new Object[] { 1, contactName1 }); mMatrixCursor.addRow(new Object[] { 2, null /* null name */ }); mMatrixCursor.addRow(new Object[] { 3, contactName2 }); mMatrixCursor.addRow(new Object[] { 4, "floopy@example.com" /* invalid name */ }); mMatrixCursor.addRow(new Object[] { 1, contactName1, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 2, null /* null name */, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 3, contactName2, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 4, "floopy@example.com" /* invalid name */, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 5, "news-group" /* invalid name */, 0, 0, 0 }); mFakeContactsContentProvider.addQueryResult(Contacts.CONTENT_URI, mMatrixCursor); final ArrayList<String> validNames = mManager.getValidNames(Contacts.CONTENT_URI); Loading @@ -78,13 +83,48 @@ public class ContactsManagerTest extends AndroidTestCase { } @Test public void testGetCount() { mMatrixCursor.addRow(new Object[] { 1, "firstname" }); mMatrixCursor.addRow(new Object[] { 2, null /* null name */ }); mMatrixCursor.addRow(new Object[] { 3, "larry" }); mMatrixCursor.addRow(new Object[] { 4, "floopy@example.com" /* invalid name */ }); public void testGetValidNamesAffinity() { final long now = System.currentTimeMillis(); final long month_ago = now - TimeUnit.MILLISECONDS.convert(31, TimeUnit.DAYS); for (int i = 0; i < ContactsManager.MAX_CONTACT_NAMES + 10; ++i) { mMatrixCursor.addRow(new Object[] { i, "name" + i, i, now, 1 }); } mFakeContactsContentProvider.addQueryResult(Contacts.CONTENT_URI, mMatrixCursor); final ArrayList<String> validNames = mManager.getValidNames(Contacts.CONTENT_URI); assertEquals(ContactsManager.MAX_CONTACT_NAMES, validNames.size()); for (int i = 0; i < 10; ++i) { assertFalse(validNames.contains("name" + i)); } for (int i = 10; i < ContactsManager.MAX_CONTACT_NAMES + 10; ++i) { assertTrue(validNames.contains("name" + i)); } } @Test public void testComputeAffinity() { final long now = System.currentTimeMillis(); final long month_ago = now - TimeUnit.MILLISECONDS.convert(31, TimeUnit.DAYS); mMatrixCursor.addRow(new Object[] { 1, "name", 1, month_ago, 1 }); mFakeContactsContentProvider.addQueryResult(Contacts.CONTENT_URI, mMatrixCursor); Cursor cursor = mFakeContactsContentProvider.query(Contacts.CONTENT_URI, ContactsDictionaryConstants.PROJECTION_ID_ONLY, null, null, null); cursor.moveToFirst(); ContactsManager.RankedContact contact = new ContactsManager.RankedContact(cursor); contact.computeAffinity(1, month_ago); assertEquals(contact.getAffinity(), 1.0f); contact.computeAffinity(2, now); assertEquals(contact.getAffinity(), (2.0f/3.0f + (float)Math.pow(0.5, 3) + 1.0f) / 3); } @Test public void testGetCount() { mMatrixCursor.addRow(new Object[] { 1, "firstname", 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 2, null /* null name */, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 3, "larry", 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 4, "floopy@example.com" /* invalid name */, 0, 0, 0 }); mFakeContactsContentProvider.addQueryResult(Contacts.CONTENT_URI, mMatrixCursor); assertEquals(4, mManager.getContactCount()); } Loading Loading
java/src/com/android/inputmethod/latin/ContactsContentObserver.java +2 −2 Original line number Diff line number Diff line Loading @@ -84,10 +84,10 @@ public class ContactsContentObserver implements Runnable { boolean haveContentsChanged() { final long startTime = SystemClock.uptimeMillis(); final int contactCount = mManager.getContactCount(); if (contactCount > ContactsDictionaryConstants.MAX_CONTACT_COUNT) { if (contactCount > ContactsDictionaryConstants.MAX_CONTACTS_PROVIDER_QUERY_LIMIT) { // If there are too many contacts then return false. In this rare case it is impossible // to include all of them anyways and the cost of rebuilding the dictionary is too high. // TODO: Sort and check only the MAX_CONTACT_COUNT most recent contacts? // TODO: Sort and check only the most recent contacts? return false; } if (contactCount != mManager.getContactCountAtLastRebuild()) { Loading
java/src/com/android/inputmethod/latin/ContactsDictionaryConstants.java +7 −3 Original line number Diff line number Diff line Loading @@ -26,7 +26,8 @@ public class ContactsDictionaryConstants { /** * Projections for {@link Contacts.CONTENT_URI} */ public static final String[] PROJECTION = { BaseColumns._ID, Contacts.DISPLAY_NAME }; public static final String[] PROJECTION = { BaseColumns._ID, Contacts.DISPLAY_NAME, Contacts.TIMES_CONTACTED, Contacts.LAST_TIME_CONTACTED, Contacts.IN_VISIBLE_GROUP }; public static final String[] PROJECTION_ID_ONLY = { BaseColumns._ID }; /** Loading @@ -36,13 +37,16 @@ public class ContactsDictionaryConstants { public static final int FREQUENCY_FOR_CONTACTS_BIGRAM = 90; /** * The maximum number of contacts that this dictionary supports. * Do not attempt to query contacts if there are more than this many entries. */ public static final int MAX_CONTACT_COUNT = 10000; public static final int MAX_CONTACTS_PROVIDER_QUERY_LIMIT = 10000; /** * Index of the column for 'name' in content providers: * Contacts & ContactsContract.Profile. */ public static final int NAME_INDEX = 1; public static final int TIMES_CONTACTED_INDEX = 2; public static final int LAST_TIME_CONTACTED_INDEX = 3; public static final int IN_VISIBLE_GROUP_INDEX = 4; }
java/src/com/android/inputmethod/latin/ContactsManager.java +93 −6 Original line number Diff line number Diff line Loading @@ -21,11 +21,17 @@ import android.database.Cursor; import android.database.sqlite.SQLiteException; import android.net.Uri; import android.provider.ContactsContract.Contacts; import android.text.TextUtils; import android.util.Log; import com.android.inputmethod.latin.common.Constants; import com.android.inputmethod.latin.common.StringUtils; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** Loading @@ -37,6 +43,63 @@ import java.util.concurrent.atomic.AtomicInteger; public class ContactsManager { private static final String TAG = "ContactsManager"; /** * Use at most this many of the highest affinity contacts. */ public static final int MAX_CONTACT_NAMES = 200; protected static class RankedContact { public final String mName; public final long mLastContactedTime; public final int mTimesContacted; public final boolean mInVisibleGroup; private float mAffinity = 0.0f; RankedContact(final Cursor cursor) { mName = cursor.getString( ContactsDictionaryConstants.NAME_INDEX); mTimesContacted = cursor.getInt( ContactsDictionaryConstants.TIMES_CONTACTED_INDEX); mLastContactedTime = cursor.getLong( ContactsDictionaryConstants.LAST_TIME_CONTACTED_INDEX); mInVisibleGroup = cursor.getInt( ContactsDictionaryConstants.IN_VISIBLE_GROUP_INDEX) == 1; } float getAffinity() { return mAffinity; } /** * Calculates the affinity with the contact based on: * - How many times it has been contacted * - How long since the last contact. * - Whether the contact is in the visible group (i.e., Contacts list). * * Note: This affinity is limited by the fact that some apps currently do not update the * LAST_TIME_CONTACTED or TIMES_CONTACTED counters. As a result, a frequently messaged * contact may still have 0 affinity. */ void computeAffinity(final int maxTimesContacted, final long currentTime) { final float timesWeight = ((float) mTimesContacted + 1) / (maxTimesContacted + 1); final long timeSinceLastContact = Math.min( Math.max(0, currentTime - mLastContactedTime), TimeUnit.MILLISECONDS.convert(180, TimeUnit.DAYS)); final float lastTimeWeight = (float) Math.pow(0.5, timeSinceLastContact / (TimeUnit.MILLISECONDS.convert(10, TimeUnit.DAYS))); final float visibleWeight = mInVisibleGroup ? 1.0f : 0.0f; mAffinity = (timesWeight + lastTimeWeight + visibleWeight) / 3; } } private static class AffinityComparator implements Comparator<RankedContact> { @Override public int compare(RankedContact contact1, RankedContact contact2) { return Float.compare(contact2.getAffinity(), contact1.getAffinity()); } } /** * Interface to implement for classes interested in getting notified for updates * to Contacts content provider. Loading Loading @@ -82,14 +145,18 @@ public class ContactsManager { * Returns all the valid names in the Contacts DB. Callers should also * call {@link #updateLocalState(ArrayList)} after they are done with result * so that the manager can cache local state for determining updates. * * These names are sorted by their affinity to the user, with favorite * contacts appearing first. */ public ArrayList<String> getValidNames(final Uri uri) { final ArrayList<String> names = new ArrayList<>(); // Check all contacts since it's not possible to find out which names have changed. // This is needed because it's possible to receive extraneous onChange events even when no // name has changed. final Cursor cursor = mContext.getContentResolver().query(uri, ContactsDictionaryConstants.PROJECTION, null, null, null); final ArrayList<RankedContact> contacts = new ArrayList<>(); int maxTimesContacted = 0; if (cursor != null) { try { if (cursor.moveToFirst()) { Loading @@ -97,7 +164,12 @@ public class ContactsManager { final String name = cursor.getString( ContactsDictionaryConstants.NAME_INDEX); if (isValidName(name)) { names.add(name); final int timesContacted = cursor.getInt( ContactsDictionaryConstants.TIMES_CONTACTED_INDEX); if (timesContacted > maxTimesContacted) { maxTimesContacted = timesContacted; } contacts.add(new RankedContact(cursor)); } cursor.moveToNext(); } Loading @@ -106,7 +178,16 @@ public class ContactsManager { cursor.close(); } } return names; final long currentTime = System.currentTimeMillis(); for (RankedContact contact : contacts) { contact.computeAffinity(maxTimesContacted, currentTime); } Collections.sort(contacts, new AffinityComparator()); final HashSet<String> names = new HashSet<>(); for (int i = 0; i < contacts.size() && names.size() < MAX_CONTACT_NAMES; ++i) { names.add(contacts.get(i).mName); } return new ArrayList<>(names); } /** Loading Loading @@ -134,11 +215,17 @@ public class ContactsManager { } private static boolean isValidName(final String name) { if (name != null && -1 == name.indexOf(Constants.CODE_COMMERCIAL_AT)) { return true; } if (TextUtils.isEmpty(name) || name.indexOf(Constants.CODE_COMMERCIAL_AT) != -1) { return false; } final boolean hasSpace = name.indexOf(Constants.CODE_SPACE) != -1; if (!hasSpace) { // Only allow an isolated word if it does not contain a hyphen. // This helps to filter out mailing lists. return name.indexOf(Constants.CODE_DASH) == -1; } return true; } /** * Updates the local state of the manager. This should be called when the callers Loading
tests/src/com/android/inputmethod/latin/ContactsManagerTest.java +50 −10 Original line number Diff line number Diff line Loading @@ -29,11 +29,15 @@ import android.test.mock.MockContentProvider; import android.test.mock.MockContentResolver; import android.test.suitebuilder.annotation.SmallTest; import com.android.inputmethod.latin.ContactsDictionaryConstants; import com.android.inputmethod.latin.ContactsManager; import org.junit.Before; import org.junit.Test; import java.util.ArrayList; import java.util.HashMap; import java.util.concurrent.TimeUnit; /** * Tests for {@link ContactsManager} Loading Loading @@ -63,12 +67,13 @@ public class ContactsManagerTest extends AndroidTestCase { @Test public void testGetValidNames() { final String contactName1 = "firstname lastname"; final String contactName1 = "firstname last-name"; final String contactName2 = "larry"; mMatrixCursor.addRow(new Object[] { 1, contactName1 }); mMatrixCursor.addRow(new Object[] { 2, null /* null name */ }); mMatrixCursor.addRow(new Object[] { 3, contactName2 }); mMatrixCursor.addRow(new Object[] { 4, "floopy@example.com" /* invalid name */ }); mMatrixCursor.addRow(new Object[] { 1, contactName1, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 2, null /* null name */, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 3, contactName2, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 4, "floopy@example.com" /* invalid name */, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 5, "news-group" /* invalid name */, 0, 0, 0 }); mFakeContactsContentProvider.addQueryResult(Contacts.CONTENT_URI, mMatrixCursor); final ArrayList<String> validNames = mManager.getValidNames(Contacts.CONTENT_URI); Loading @@ -78,13 +83,48 @@ public class ContactsManagerTest extends AndroidTestCase { } @Test public void testGetCount() { mMatrixCursor.addRow(new Object[] { 1, "firstname" }); mMatrixCursor.addRow(new Object[] { 2, null /* null name */ }); mMatrixCursor.addRow(new Object[] { 3, "larry" }); mMatrixCursor.addRow(new Object[] { 4, "floopy@example.com" /* invalid name */ }); public void testGetValidNamesAffinity() { final long now = System.currentTimeMillis(); final long month_ago = now - TimeUnit.MILLISECONDS.convert(31, TimeUnit.DAYS); for (int i = 0; i < ContactsManager.MAX_CONTACT_NAMES + 10; ++i) { mMatrixCursor.addRow(new Object[] { i, "name" + i, i, now, 1 }); } mFakeContactsContentProvider.addQueryResult(Contacts.CONTENT_URI, mMatrixCursor); final ArrayList<String> validNames = mManager.getValidNames(Contacts.CONTENT_URI); assertEquals(ContactsManager.MAX_CONTACT_NAMES, validNames.size()); for (int i = 0; i < 10; ++i) { assertFalse(validNames.contains("name" + i)); } for (int i = 10; i < ContactsManager.MAX_CONTACT_NAMES + 10; ++i) { assertTrue(validNames.contains("name" + i)); } } @Test public void testComputeAffinity() { final long now = System.currentTimeMillis(); final long month_ago = now - TimeUnit.MILLISECONDS.convert(31, TimeUnit.DAYS); mMatrixCursor.addRow(new Object[] { 1, "name", 1, month_ago, 1 }); mFakeContactsContentProvider.addQueryResult(Contacts.CONTENT_URI, mMatrixCursor); Cursor cursor = mFakeContactsContentProvider.query(Contacts.CONTENT_URI, ContactsDictionaryConstants.PROJECTION_ID_ONLY, null, null, null); cursor.moveToFirst(); ContactsManager.RankedContact contact = new ContactsManager.RankedContact(cursor); contact.computeAffinity(1, month_ago); assertEquals(contact.getAffinity(), 1.0f); contact.computeAffinity(2, now); assertEquals(contact.getAffinity(), (2.0f/3.0f + (float)Math.pow(0.5, 3) + 1.0f) / 3); } @Test public void testGetCount() { mMatrixCursor.addRow(new Object[] { 1, "firstname", 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 2, null /* null name */, 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 3, "larry", 0, 0, 0 }); mMatrixCursor.addRow(new Object[] { 4, "floopy@example.com" /* invalid name */, 0, 0, 0 }); mFakeContactsContentProvider.addQueryResult(Contacts.CONTENT_URI, mMatrixCursor); assertEquals(4, mManager.getContactCount()); } Loading