diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/com/android/dialer/database/DialerDatabaseHelper.java | 826 | ||||
-rw-r--r-- | src/com/android/dialer/dialpad/DialpadFragment.java | 51 | ||||
-rw-r--r-- | src/com/android/dialer/dialpad/SmartDialCache.java | 408 | ||||
-rw-r--r-- | src/com/android/dialer/dialpad/SmartDialLoaderTask.java | 95 | ||||
-rw-r--r-- | src/com/android/dialer/dialpad/SmartDialNameMatcher.java | 49 | ||||
-rw-r--r-- | src/com/android/dialer/dialpad/SmartDialPrefix.java | 608 | ||||
-rw-r--r-- | src/com/android/dialer/dialpad/SmartDialTrie.java | 671 |
7 files changed, 1492 insertions, 1216 deletions
diff --git a/src/com/android/dialer/database/DialerDatabaseHelper.java b/src/com/android/dialer/database/DialerDatabaseHelper.java new file mode 100644 index 000000000..fd402c9ec --- /dev/null +++ b/src/com/android/dialer/database/DialerDatabaseHelper.java @@ -0,0 +1,826 @@ +/* + * Copyright (C) 2013 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.dialer.database; + +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteStatement; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.Directory; +import com.android.contacts.common.test.NeededForTesting; +import android.util.Log; + +import com.android.contacts.common.util.StopWatch; +import com.android.dialer.dialpad.SmartDialNameMatcher; +import com.android.dialer.dialpad.SmartDialPrefix; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Objects; +import com.google.common.base.Preconditions; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Database helper for smart dial. Designed as a singleton to make sure there is + * only one access point to the database. Provides methods to maintain, update, + * and query the database. + */ +public class DialerDatabaseHelper extends SQLiteOpenHelper { + private static final String TAG = "DialerDatabaseHelper"; + private static final boolean DEBUG = false; + + private static DialerDatabaseHelper sSingleton = null; + + private static final Object mLock = new Object(); + private static final AtomicBoolean sInUpdate = new AtomicBoolean(false); + private final Context mContext; + + /** + * SmartDial DB version ranges: + * <pre> + * 0-98 KeyLimePie + * </pre> + */ + private static final int DATABASE_VERSION = 1; + private static final String SMARTDIAL_DATABASE_NAME = "dialer.db"; + + /** + * Saves the last update time of smart dial databases to shared preferences. + */ + private static final String DATABASE_LAST_CREATED_SHARED_PREF = "com.android.dialer_smartdial"; + private static final String LAST_UPDATED_MILLIS = "last_updated_millis"; + + private static final int MAX_ENTRIES = 3; + + public interface Tables { + /** Saves the necessary smart dial information of all contacts. */ + static final String SMARTDIAL_TABLE = "smartdial_table"; + /** Saves all possible prefixes to refer to a contacts.*/ + static final String PREFIX_TABLE = "prefix_table"; + } + + public interface SmartDialDbColumns { + static final String _ID = "id"; + static final String NUMBER = "phone_number"; + static final String CONTACT_ID = "contact_id"; + static final String LOOKUP_KEY = "lookup_key"; + static final String DISPLAY_NAME_PRIMARY = "display_name"; + static final String LAST_TIME_USED = "last_time_used"; + static final String TIMES_USED = "times_used"; + static final String STARRED = "starred"; + static final String IS_SUPER_PRIMARY = "is_super_primary"; + static final String IN_VISIBLE_GROUP = "in_visible_group"; + static final String IS_PRIMARY = "is_primary"; + static final String LAST_SMARTDIAL_UPDATE_TIME = "last_smartdial_update_time"; + } + + public static interface PrefixColumns extends BaseColumns { + static final String PREFIX = "prefix"; + static final String CONTACT_ID = "contact_id"; + } + + /** Query options for querying the contact database.*/ + public static interface PhoneQuery { + static final Uri URI = Phone.CONTENT_URI.buildUpon(). + appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, + String.valueOf(Directory.DEFAULT)). + appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true"). + build(); + + static final String[] PROJECTION = new String[] { + Phone._ID, // 0 + Phone.TYPE, // 1 + Phone.LABEL, // 2 + Phone.NUMBER, // 3 + Phone.CONTACT_ID, // 4 + Phone.LOOKUP_KEY, // 5 + Phone.DISPLAY_NAME_PRIMARY, // 6 + Data.LAST_TIME_USED, // 7 + Data.TIMES_USED, // 8 + Contacts.STARRED, // 9 + Data.IS_SUPER_PRIMARY, // 10 + Contacts.IN_VISIBLE_GROUP, // 11 + Data.IS_PRIMARY, // 12 + }; + + static final int PHONE_ID = 0; + static final int PHONE_TYPE = 1; + static final int PHONE_LABEL = 2; + static final int PHONE_NUMBER = 3; + static final int PHONE_CONTACT_ID = 4; + static final int PHONE_LOOKUP_KEY = 5; + static final int PHONE_DISPLAY_NAME = 6; + static final int PHONE_LAST_TIME_USED = 7; + static final int PHONE_TIMES_USED = 8; + static final int PHONE_STARRED = 9; + static final int PHONE_IS_SUPER_PRIMARY = 10; + static final int PHONE_IN_VISIBLE_GROUP = 11; + static final int PHONE_IS_PRIMARY = 12; + + /** Selects only rows that have been updated after a certain time stamp.*/ + static final String SELECT_UPDATED_CLAUSE = + Phone.CONTACT_LAST_UPDATED_TIMESTAMP + " > ?"; + } + + /** Query options for querying the deleted contact database.*/ + public static interface DeleteContactQuery { + static final Uri URI = ContactsContract.DeletedContacts.CONTENT_URI; + + static final String[] PROJECTION = new String[] { + ContactsContract.DeletedContacts.CONTACT_ID, // 0 + ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP, // 1 + }; + + static final int DELETED_CONTACT_ID = 0; + static final int DELECTED_TIMESTAMP = 1; + + /** Selects only rows that have been deleted after a certain time stamp.*/ + public static final String SELECT_UPDATED_CLAUSE = + ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP + " > ?"; + } + + /** + * Gets the sorting order for the smartdial table. This computes a SQL "ORDER BY" argument by + * composing contact status and recent contact details together. + */ + private static interface SmartDialSortingOrder { + /** Current contacts - those contacted within the last 3 days (in milliseconds) */ + static final long LAST_TIME_USED_CURRENT_MS = 3L * 24 * 60 * 60 * 1000; + /** Recent contacts - those contacted within the last 30 days (in milliseconds) */ + static final long LAST_TIME_USED_RECENT_MS = 30L * 24 * 60 * 60 * 1000; + + /** Time since last contact. */ + static final String TIME_SINCE_LAST_USED_MS = "( ?1 - " + + Tables.SMARTDIAL_TABLE + "." + SmartDialDbColumns.LAST_TIME_USED + ")"; + + /** Contacts that have been used in the past 3 days rank higher than contacts that have + * been used in the past 30 days, which rank higher than contacts that have not been used + * in recent 30 days. + */ + static final String SORT_BY_DATA_USAGE = + "(CASE WHEN " + TIME_SINCE_LAST_USED_MS + " < " + LAST_TIME_USED_CURRENT_MS + + " THEN 0 " + + " WHEN " + TIME_SINCE_LAST_USED_MS + " < " + LAST_TIME_USED_RECENT_MS + + " THEN 1 " + + " ELSE 2 END)"; + + /** This sort order is similar to that used by the ContactsProvider when returning a list + * of frequently called contacts. + */ + static final String SORT_ORDER = + Tables.SMARTDIAL_TABLE + "." + SmartDialDbColumns.STARRED + " DESC, " + + Tables.SMARTDIAL_TABLE + "." + SmartDialDbColumns.IS_SUPER_PRIMARY + " DESC, " + + SORT_BY_DATA_USAGE + ", " + + Tables.SMARTDIAL_TABLE + "." + SmartDialDbColumns.TIMES_USED + " DESC, " + + Tables.SMARTDIAL_TABLE + "." + SmartDialDbColumns.IN_VISIBLE_GROUP + " DESC, " + + Tables.SMARTDIAL_TABLE + "." + SmartDialDbColumns.DISPLAY_NAME_PRIMARY + ", " + + Tables.SMARTDIAL_TABLE + "." + SmartDialDbColumns.CONTACT_ID + ", " + + Tables.SMARTDIAL_TABLE + "." + SmartDialDbColumns.IS_PRIMARY + " DESC"; + } + + /** + * Simple data format for a contact, containing only information needed for showing up in + * smart dial interface. + */ + public static class ContactNumber { + public final String displayName; + public final String lookupKey; + public final long id; + public final String phoneNumber; + + public ContactNumber(long id, String displayName, String phoneNumber, String lookupKey) { + this.displayName = displayName; + this.lookupKey = lookupKey; + this.id = id; + this.phoneNumber = phoneNumber; + } + + @Override + public int hashCode() { + return Objects.hashCode(displayName, id, lookupKey, phoneNumber); + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (object instanceof ContactNumber) { + final ContactNumber that = (ContactNumber) object; + return Objects.equal(this.displayName, that.displayName) + && Objects.equal(this.id, that.id) + && Objects.equal(this.lookupKey, that.lookupKey) + && Objects.equal(this.phoneNumber, that.phoneNumber); + } + return false; + } + } + + /** + * Data format for finding duplicated contacts. + */ + private class ContactMatch { + private final String lookupKey; + private final long id; + + public ContactMatch(String lookupKey, long id) { + this.lookupKey = lookupKey; + this.id = id; + } + + @Override + public int hashCode() { + return Objects.hashCode(lookupKey, id); + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (object instanceof ContactMatch) { + final ContactMatch that = (ContactMatch) object; + return Objects.equal(this.lookupKey, that.lookupKey) + && Objects.equal(this.id, that.id); + } + return false; + } + } + + /** + * Access function to get the singleton instance of DialerDatabaseHelper. + */ + public static synchronized DialerDatabaseHelper getInstance(Context context) { + if (DEBUG) { + Log.v(TAG, "Getting Instance"); + } + if (sSingleton == null) { + sSingleton = new DialerDatabaseHelper(context, SMARTDIAL_DATABASE_NAME); + } + return sSingleton; + } + + /** + * Returns a new instance for unit tests. The database will be created in memory. + */ + @NeededForTesting + static DialerDatabaseHelper getNewInstanceForTest(Context context) { + return new DialerDatabaseHelper(context, null); + } + + protected DialerDatabaseHelper(Context context, String databaseName) { + super(context, databaseName, null, DATABASE_VERSION); + mContext = Preconditions.checkNotNull(context, "Context must not be null"); + } + + /** + * Creates tables in the database when database is created for the first time. + * + * @param db The database. + */ + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL("CREATE TABLE " + Tables.SMARTDIAL_TABLE + " (" + + SmartDialDbColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + SmartDialDbColumns.NUMBER + " TEXT," + + SmartDialDbColumns.CONTACT_ID + " INTEGER," + + SmartDialDbColumns.LOOKUP_KEY + " TEXT," + + SmartDialDbColumns.DISPLAY_NAME_PRIMARY + " TEXT, " + + SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + " LONG, " + + SmartDialDbColumns.LAST_TIME_USED + " LONG, " + + SmartDialDbColumns.TIMES_USED + " INTEGER, " + + SmartDialDbColumns.STARRED + " INTEGER, " + + SmartDialDbColumns.IS_SUPER_PRIMARY + " INTEGER, " + + SmartDialDbColumns.IN_VISIBLE_GROUP + " INTEGER, " + + SmartDialDbColumns.IS_PRIMARY + " INTEGER" + + ");"); + + db.execSQL("CREATE TABLE " + Tables.PREFIX_TABLE + " (" + + PrefixColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + PrefixColumns.PREFIX + " TEXT COLLATE NOCASE, " + + PrefixColumns.CONTACT_ID + " INTEGER" + + ");"); + } + + /** + * Starts the database upgrade process in the background. + */ + public void startSmartDialUpdateThread() { + new SmartDialUpdateAsyncTask().execute(); + } + + private class SmartDialUpdateAsyncTask extends AsyncTask { + @Override + protected Object doInBackground(Object[] objects) { + if (DEBUG) { + Log.v(TAG, "Updating database"); + } + updateSmartDialDatabase(); + return null; + } + + @Override + protected void onCancelled() { + if (DEBUG) { + Log.v(TAG, "Updating Cancelled"); + } + super.onCancelled(); + } + + @Override + protected void onPostExecute(Object o) { + if (DEBUG) { + Log.v(TAG, "Updating Finished"); + } + super.onPostExecute(o); + } + } + /** + * Removes rows in the smartdial database that matches the contacts that have been deleted + * by other apps since last update. + * + * @param db Database pointer to the dialer database. + * @param last_update_time Time stamp of last update on the smartdial database + */ + private void removeDeletedContacts(SQLiteDatabase db, String last_update_time) { + final Cursor deletedContactCursor = mContext.getContentResolver().query( + DeleteContactQuery.URI, + DeleteContactQuery.PROJECTION, + DeleteContactQuery.SELECT_UPDATED_CLAUSE, + new String[] {last_update_time}, null); + + db.beginTransaction(); + try { + while (deletedContactCursor.moveToNext()) { + final Long deleteContactId = + deletedContactCursor.getLong(DeleteContactQuery.DELETED_CONTACT_ID); + db.delete(Tables.SMARTDIAL_TABLE, + SmartDialDbColumns.CONTACT_ID + "=" + deleteContactId, null); + db.delete(Tables.PREFIX_TABLE, + PrefixColumns.CONTACT_ID + "=" + deleteContactId, null); + } + + db.setTransactionSuccessful(); + } finally { + deletedContactCursor.close(); + db.endTransaction(); + } + } + + /** + * Removes potentially corrupted entries in the database. These contacts may be added before + * the previous instance of the dialer was destroyed for some reason. For data integrity, we + * delete all of them. + + * @param db Database pointer to the dialer database. + * @param last_update_time Time stamp of last successful update of the dialer database. + */ + private void removePotentiallyCorruptedContacts(SQLiteDatabase db, String last_update_time) { + db.delete(Tables.PREFIX_TABLE, + PrefixColumns.CONTACT_ID + " IN " + + "(SELECT " + SmartDialDbColumns.CONTACT_ID + " FROM " + Tables.SMARTDIAL_TABLE + + " WHERE " + SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + " > " + + last_update_time + ")", + null); + db.delete(Tables.SMARTDIAL_TABLE, + SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + " > " + last_update_time, null); + } + + /** + * Removes all entries in the smartdial contact database. + */ + @VisibleForTesting + void removeAllContacts(SQLiteDatabase db) { + db.delete(Tables.SMARTDIAL_TABLE, null, null); + db.delete(Tables.PREFIX_TABLE, null, null); + } + + /** + * Counts number of rows of the prefix table. + */ + @VisibleForTesting + int countPrefixTableRows(SQLiteDatabase db) { + return (int)DatabaseUtils.longForQuery(db, "SELECT COUNT(1) FROM " + Tables.PREFIX_TABLE, + null); + } + + /** + * Removes rows in the smartdial database that matches updated contacts. + * + * @param db Database pointer to the smartdial database + * @param updatedContactCursor Cursor pointing to the list of recently updated contacts. + */ + private void removeUpdatedContacts(SQLiteDatabase db, Cursor updatedContactCursor) { + db.beginTransaction(); + try { + while (updatedContactCursor.moveToNext()) { + final Long contactId = updatedContactCursor.getLong(PhoneQuery.PHONE_CONTACT_ID); + + db.delete(Tables.SMARTDIAL_TABLE, SmartDialDbColumns.CONTACT_ID + "=" + + contactId, null); + db.delete(Tables.PREFIX_TABLE, PrefixColumns.CONTACT_ID + "=" + + contactId, null); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + /** + * Inserts updated contacts as rows to the smartdial table. + * + * @param db Database pointer to the smartdial database. + * @param updatedContactCursor Cursor pointing to the list of recently updated contacts. + * @param currentMillis Current time to be recorded in the smartdial table as update timestamp. + */ + @VisibleForTesting + protected void insertUpdatedContactsAndNumberPrefix(SQLiteDatabase db, + Cursor updatedContactCursor, Long currentMillis) { + db.beginTransaction(); + try { + final String sqlInsert = "INSERT INTO " + Tables.SMARTDIAL_TABLE + " (" + + SmartDialDbColumns.NUMBER + ", " + + SmartDialDbColumns.CONTACT_ID + ", " + + SmartDialDbColumns.LOOKUP_KEY + ", " + + SmartDialDbColumns.DISPLAY_NAME_PRIMARY + ", " + + SmartDialDbColumns.LAST_TIME_USED + ", " + + SmartDialDbColumns.TIMES_USED + ", " + + SmartDialDbColumns.STARRED + ", " + + SmartDialDbColumns.IS_SUPER_PRIMARY + ", " + + SmartDialDbColumns.IN_VISIBLE_GROUP+ ", " + + SmartDialDbColumns.IS_PRIMARY + ", " + + SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + ") " + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + final SQLiteStatement insert = db.compileStatement(sqlInsert); + + final String numberSqlInsert = "INSERT INTO " + Tables.PREFIX_TABLE + " (" + + PrefixColumns.CONTACT_ID + ", " + + PrefixColumns.PREFIX + ") " + + " VALUES (?, ?)"; + final SQLiteStatement numberInsert = db.compileStatement(numberSqlInsert); + + updatedContactCursor.moveToPosition(-1); + while (updatedContactCursor.moveToNext()) { + insert.bindString(1, updatedContactCursor.getString(PhoneQuery.PHONE_NUMBER)); + insert.bindLong(2, updatedContactCursor.getLong(PhoneQuery.PHONE_CONTACT_ID)); + insert.bindString(3, updatedContactCursor.getString(PhoneQuery.PHONE_LOOKUP_KEY)); + insert.bindString(4, updatedContactCursor.getString(PhoneQuery.PHONE_DISPLAY_NAME)); + insert.bindLong(5, updatedContactCursor.getLong(PhoneQuery.PHONE_LAST_TIME_USED)); + insert.bindLong(6, updatedContactCursor.getInt(PhoneQuery.PHONE_TIMES_USED)); + insert.bindLong(7, updatedContactCursor.getInt(PhoneQuery.PHONE_STARRED)); + insert.bindLong(8, updatedContactCursor.getInt(PhoneQuery.PHONE_IS_SUPER_PRIMARY)); + insert.bindLong(9, updatedContactCursor.getInt(PhoneQuery.PHONE_IN_VISIBLE_GROUP)); + insert.bindLong(10, updatedContactCursor.getInt(PhoneQuery.PHONE_IS_PRIMARY)); + insert.bindLong(11, currentMillis); + insert.executeInsert(); + insert.clearBindings(); + + final String contactPhoneNumber = + updatedContactCursor.getString(PhoneQuery.PHONE_NUMBER); + final ArrayList<String> numberPrefixes = + SmartDialPrefix.parseToNumberTokens(contactPhoneNumber); + + for (String numberPrefix : numberPrefixes) { + numberInsert.bindLong(1, updatedContactCursor.getLong( + PhoneQuery.PHONE_CONTACT_ID)); + numberInsert.bindString(2, numberPrefix); + numberInsert.executeInsert(); + numberInsert.clearBindings(); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + /** + * Inserts prefixes of contact names to the prefix table. + * + * @param db Database pointer to the smartdial database. + * @param nameCursor Cursor pointing to the list of distinct updated contacts. + */ + @VisibleForTesting + void insertNamePrefixes(SQLiteDatabase db, Cursor nameCursor) { + final int columnIndexName = nameCursor.getColumnIndex( + SmartDialDbColumns.DISPLAY_NAME_PRIMARY); + final int columnIndexContactId = nameCursor.getColumnIndex(SmartDialDbColumns.CONTACT_ID); + + db.beginTransaction(); + try { + final String sqlInsert = "INSERT INTO " + Tables.PREFIX_TABLE + " (" + + PrefixColumns.CONTACT_ID + ", " + + PrefixColumns.PREFIX + ") " + + " VALUES (?, ?)"; + final SQLiteStatement insert = db.compileStatement(sqlInsert); + + while (nameCursor.moveToNext()) { + /** Computes a list of prefixes of a given contact name. */ + final ArrayList<String> namePrefixes = + SmartDialPrefix.generateNamePrefixes(nameCursor.getString(columnIndexName)); + + for (String namePrefix : namePrefixes) { + insert.bindLong(1, nameCursor.getLong(columnIndexContactId)); + insert.bindString(2, namePrefix); + insert.executeInsert(); + insert.clearBindings(); + } + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + /** + * Updates the smart dial and prefix database. + * This method queries the Delta API to get changed contacts since last update, and updates the + * records in smartdial database and prefix database accordingly. + * It also queries the deleted contact database to remove newly deleted contacts since last + * update. + */ + public void updateSmartDialDatabase() { + final SQLiteDatabase db = getWritableDatabase(); + + synchronized(mLock) { + if (DEBUG) { + Log.v(TAG, "Starting to update database"); + } + final StopWatch stopWatch = DEBUG ? StopWatch.start("Updating databases") : null; + + /** Gets the last update time on the database. */ + final SharedPreferences databaseLastUpdateSharedPref = mContext.getSharedPreferences( + DATABASE_LAST_CREATED_SHARED_PREF, Context.MODE_PRIVATE); + final String lastUpdateMillis = String.valueOf( + databaseLastUpdateSharedPref.getLong(LAST_UPDATED_MILLIS, 0)); + + if (DEBUG) { + Log.v(TAG, "Last updated at " + lastUpdateMillis); + } + /** Queries the contact database to get contacts that have been updated since the last + * update time. + */ + final Cursor updatedContactCursor = mContext.getContentResolver().query(PhoneQuery.URI, + PhoneQuery.PROJECTION, PhoneQuery.SELECT_UPDATED_CLAUSE, + new String[]{lastUpdateMillis}, null); + + /** Sets the time after querying the database as the current update time. */ + final Long currentMillis = System.currentTimeMillis(); + + if (DEBUG) { + stopWatch.lap("Queried the Contacts database"); + } + + if (updatedContactCursor == null) { + if (DEBUG) { + Log.e(TAG, "SmartDial query received null for cursor"); + } + return; + } + + /** Prevents the app from reading the dialer database when updating. */ + sInUpdate.getAndSet(true); + + /** Removes contacts that have been deleted. */ + removeDeletedContacts(db, lastUpdateMillis); + removePotentiallyCorruptedContacts(db, lastUpdateMillis); + + if (DEBUG) { + stopWatch.lap("Finished deleting deleted entries"); + } + + try { + /** If the database did not exist before, jump through deletion as there is nothing + * to delete. + */ + if (!lastUpdateMillis.equals("0")) { + /** Removes contacts that have been updated. Updated contact information will be + * inserted later. + */ + removeUpdatedContacts(db, updatedContactCursor); + if (DEBUG) { + stopWatch.lap("Finished deleting updated entries"); + } + } + + /** Inserts recently updated contacts to the smartdial database.*/ + insertUpdatedContactsAndNumberPrefix(db, updatedContactCursor, currentMillis); + if (DEBUG) { + stopWatch.lap("Finished building the smart dial table"); + } + } finally { + /** Inserts prefixes of phone numbers into the prefix table.*/ + updatedContactCursor.close(); + } + + /** Gets a list of distinct contacts which have been updated, and adds the name prefixes + * of these contacts to the prefix table. + */ + final Cursor nameCursor = db.rawQuery( + "SELECT DISTINCT " + + SmartDialDbColumns.DISPLAY_NAME_PRIMARY + ", " + SmartDialDbColumns.CONTACT_ID + + " FROM " + Tables.SMARTDIAL_TABLE + + " WHERE " + SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + + " = " + Long.toString(currentMillis), + new String[] {}); + if (DEBUG) { + stopWatch.lap("Queried the smart dial table for contact names"); + } + + if (nameCursor != null) { + try { + /** Inserts prefixes of names into the prefix table.*/ + insertNamePrefixes(db, nameCursor); + if (DEBUG) { + stopWatch.lap("Finished building the name prefix table"); + } + } finally { + nameCursor.close(); + } + } + + /** Creates index on contact_id for fast JOIN operation. */ + db.execSQL("CREATE INDEX IF NOT EXISTS smartdial_contact_id_index ON " + + Tables.SMARTDIAL_TABLE + " (" + SmartDialDbColumns.CONTACT_ID + ");"); + /** Creates index on last_smartdial_update_time for fast SELECT operation. */ + db.execSQL("CREATE INDEX IF NOT EXISTS smartdial_last_update_index ON " + + Tables.SMARTDIAL_TABLE + " (" + + SmartDialDbColumns.LAST_SMARTDIAL_UPDATE_TIME + ");"); + /** Creates index on sorting fields for fast sort operation. */ + db.execSQL("CREATE INDEX IF NOT EXISTS smartdial_sort_index ON " + + Tables.SMARTDIAL_TABLE + " (" + + SmartDialDbColumns.STARRED + ", " + + SmartDialDbColumns.IS_SUPER_PRIMARY + ", " + + SmartDialDbColumns.LAST_TIME_USED + ", " + + SmartDialDbColumns.TIMES_USED + ", " + + SmartDialDbColumns.IN_VISIBLE_GROUP + ", " + + SmartDialDbColumns.DISPLAY_NAME_PRIMARY + ", " + + SmartDialDbColumns.CONTACT_ID + ", " + + SmartDialDbColumns.IS_PRIMARY + + ");"); + /** Creates index on prefix for fast SELECT operation. */ + db.execSQL("CREATE INDEX IF NOT EXISTS nameprefix_index ON " + + Tables.PREFIX_TABLE + " (" + PrefixColumns.PREFIX + ");"); + /** Creates index on contact_id for fast JOIN operation. */ + db.execSQL("CREATE INDEX IF NOT EXISTS nameprefix_contact_id_index ON " + + Tables.PREFIX_TABLE + " (" + PrefixColumns.CONTACT_ID + ");"); + + if (DEBUG) { + stopWatch.lap(TAG + "Finished recreating index"); + } + + /** Updates the database index statistics.*/ + db.execSQL("ANALYZE " + Tables.SMARTDIAL_TABLE); + db.execSQL("ANALYZE " + Tables.PREFIX_TABLE); + db.execSQL("ANALYZE smartdial_contact_id_index"); + db.execSQL("ANALYZE smartdial_last_update_index"); + db.execSQL("ANALYZE nameprefix_index"); + db.execSQL("ANALYZE nameprefix_contact_id_index"); + if (DEBUG) { + stopWatch.stopAndLog(TAG + "Finished updating index stats", 0); + } + + sInUpdate.getAndSet(false); + + final SharedPreferences.Editor editor = databaseLastUpdateSharedPref.edit(); + editor.putLong(LAST_UPDATED_MILLIS, currentMillis); + editor.commit(); + } + } + + + /** + * Returns a list of candidate contacts where the query is a prefix of the dialpad index of + * the contact's name or phone number. + * + * @param query The prefix of a contact's dialpad index. + * @return A list of top candidate contacts that will be suggested to user to match their input. + */ + public ArrayList<ContactNumber> getLooseMatches(String query, + SmartDialNameMatcher nameMatcher) { + final boolean inUpdate = sInUpdate.get(); + if (inUpdate) { + return Lists.newArrayList(); + } + + final SQLiteDatabase db = getReadableDatabase(); + + /** Uses SQL query wildcard '%' to represent prefix matching.*/ + final String looseQuery = query + "%"; + + final ArrayList<ContactNumber> result = Lists.newArrayList(); + + final StopWatch stopWatch = DEBUG ? StopWatch.start(":Name Prefix query") : null; + + final String currentTimeStamp = Long.toString(System.currentTimeMillis()); + + /** Queries the database to find contacts that have an index matching the query prefix. */ + final Cursor cursor = db.rawQuery("SELECT " + + SmartDialDbColumns.DISPLAY_NAME_PRIMARY + ", " + + SmartDialDbColumns.NUMBER + ", " + + SmartDialDbColumns.CONTACT_ID + ", " + + SmartDialDbColumns.LOOKUP_KEY + + " FROM " + Tables.SMARTDIAL_TABLE + " WHERE " + + SmartDialDbColumns.CONTACT_ID + " IN " + + " (SELECT " + PrefixColumns.CONTACT_ID + + " FROM " + Tables.PREFIX_TABLE + + " WHERE " + Tables.PREFIX_TABLE + "." + PrefixColumns.PREFIX + + " LIKE '" + looseQuery + "')" + + " ORDER BY " + SmartDialSortingOrder.SORT_ORDER, + new String[] {currentTimeStamp}); + + if (DEBUG) { + stopWatch.lap("Prefix query completed"); + } + + /** Gets the column ID from the cursor.*/ + final int columnDisplayNamePrimary = 0; + final int columnNumber = 1; + final int columnId = 2; + final int columnLookupKey = 3; + if (DEBUG) { + stopWatch.lap("Found column IDs"); + } + + final Set<ContactMatch> duplicates = new HashSet<ContactMatch>(); + int counter = 0; + try { + if (DEBUG) { + stopWatch.lap("Moved cursor to start"); + } + /** Iterates the cursor to find top contact suggestions without duplication.*/ + while ((cursor.moveToNext()) && (counter < MAX_ENTRIES)) { + final String displayName = cursor.getString(columnDisplayNamePrimary); + final String phoneNumber = cursor.getString(columnNumber); + final long id = cursor.getLong(columnId); + final String lookupKey = cursor.getString(columnLookupKey); + + /** If a contact already exists and another phone number of the contact is being + * processed, skip the second instance. + */ + final ContactMatch contactMatch = new ContactMatch(lookupKey, id); + if (duplicates.contains(contactMatch)) { + continue; + } + + /** + * If the contact has either the name or number that matches the query, add to the + * result. + */ + if (nameMatcher.matches(displayName) || + nameMatcher.matchesNumber(phoneNumber, query) != null) { + /** If a contact has not been added, add it to the result and the hash set.*/ + duplicates.add(contactMatch); + result.add(new ContactNumber(id, displayName, phoneNumber, lookupKey)); + counter++; + if (DEBUG) { + stopWatch.lap("Added one result"); + } + } + } + + if (DEBUG) { + stopWatch.stopAndLog(TAG + "Finished loading cursor", 0); + } + } finally { + cursor.close(); + } + return result; + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + + } +} diff --git a/src/com/android/dialer/dialpad/DialpadFragment.java b/src/com/android/dialer/dialpad/DialpadFragment.java index a8984bd26..f783d2705 100644 --- a/src/com/android/dialer/dialpad/DialpadFragment.java +++ b/src/com/android/dialer/dialpad/DialpadFragment.java @@ -26,7 +26,6 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.res.Resources; -import android.database.ContentObserver; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; @@ -35,7 +34,6 @@ import android.media.ToneGenerator; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; -import android.os.Handler; import android.os.RemoteException; import android.os.ServiceManager; import android.os.SystemProperties; @@ -62,12 +60,10 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; -import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.BaseAdapter; import android.widget.EditText; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.ListView; import android.widget.PopupMenu; import android.widget.RelativeLayout; @@ -82,6 +78,7 @@ import com.android.contacts.common.util.StopWatch; import com.android.dialer.DialtactsActivity; import com.android.dialer.R; import com.android.dialer.SpecialCharSequenceMgr; +import com.android.dialer.database.DialerDatabaseHelper; import com.android.dialer.interactions.PhoneNumberInteraction; import com.android.dialer.util.OrientationUtil; import com.android.internal.telephony.ITelephony; @@ -156,8 +153,6 @@ public class DialpadFragment extends Fragment */ private SmartDialController mSmartDialAdapter; - private SmartDialCache mSmartDialCache; - /** * Use latin character map by default */ @@ -169,6 +164,8 @@ public class DialpadFragment extends Fragment */ private boolean mSmartDialEnabled = false; + private DialerDatabaseHelper mDialerDatabaseHelper; + /** * Regular expression prohibiting manual phone call. Can be empty, which means "no rule". */ @@ -300,6 +297,10 @@ public class DialpadFragment extends Fragment mFirstLaunch = true; mContactsPrefs = new ContactsPreferences(getActivity()); mCurrentCountryIso = GeoUtil.getCurrentCountryIso(getActivity()); + + mDialerDatabaseHelper = DialerDatabaseHelper.getInstance(getActivity()); + SmartDialPrefix.initializeNanpSettings(getActivity()); + try { mHaptic.init(getActivity(), getResources().getBoolean(R.bool.config_enable_dialer_key_vibration)); @@ -1653,20 +1654,6 @@ public class DialpadFragment extends Fragment return intent; } - @Override - public void setUserVisibleHint(boolean isVisibleToUser) { - super.setUserVisibleHint(isVisibleToUser); - if (mSmartDialEnabled && isVisibleToUser && mSmartDialCache != null) { - // This is called every time the dialpad fragment comes into view. The first - // time the dialer is launched, mSmartDialEnabled is always false as it has not been - // read from settings(in onResume) yet at the point where setUserVisibleHint is called - // for the first time, so the caching on first launch will happen in onResume instead. - // This covers only the case where the dialer is launched in the call log or - // contacts tab, and then the user swipes to the dialpad. - mSmartDialCache.cacheIfNeeded(false); - } - } - private String mLastDigitsForSmartDial; private void loadSmartDialEntries() { @@ -1675,11 +1662,6 @@ public class DialpadFragment extends Fragment return; } - if (mSmartDialCache == null) { - Log.e(TAG, "Trying to load smart dialing entries from a null cache"); - return; - } - // Update only when the digits have changed. final String digits = SmartDialNameMatcher.normalizeNumber(mDigits.getText().toString(), mSmartDialMap); @@ -1691,7 +1673,7 @@ public class DialpadFragment extends Fragment if (digits.length() < 1) { mSmartDialAdapter.clear(); } else { - final SmartDialLoaderTask task = new SmartDialLoaderTask(this, digits, mSmartDialCache); + final SmartDialLoaderTask task = new SmartDialLoaderTask(this, digits, getActivity()); task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new String[] {}); } } @@ -1708,24 +1690,15 @@ public class DialpadFragment extends Fragment // Handle smart dialing related state if (mSmartDialEnabled) { mSmartDialContainer.setVisibility(View.VISIBLE); - mSmartDialCache = SmartDialCache.getInstance(getActivity(), - mContactsPrefs.getDisplayOrder(), mSmartDialMap); - // Don't force recache if this is the first time onResume is being called, since - // caching should already happen in setUserVisibleHint. - if (!mFirstLaunch || getUserVisibleHint()) { - // This forced recache covers the cases where the dialer was running before and - // was brought back into the foreground, or the dialer was launched for the first - // time and displays the dialpad fragment immediately. If the dialpad fragment - // hasn't actually become visible throughout the entire activity's lifecycle, it - // is possible that caching hasn't happened yet. In this case, we can force a - // recache anyway, since we are not worried about startup performance anymore. - mSmartDialCache.cacheIfNeeded(true); + + if (DEBUG) { + Log.w(TAG, "Creating smart dial database"); } + mDialerDatabaseHelper.startSmartDialUpdateThread(); } else { if (mSmartDialContainer != null) { mSmartDialContainer.setVisibility(View.GONE); } - mSmartDialCache = null; } } diff --git a/src/com/android/dialer/dialpad/SmartDialCache.java b/src/com/android/dialer/dialpad/SmartDialCache.java deleted file mode 100644 index 3d4a563af..000000000 --- a/src/com/android/dialer/dialpad/SmartDialCache.java +++ /dev/null @@ -1,408 +0,0 @@ -/* - * Copyright (C) 2012 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.dialer.dialpad; - -import static com.android.dialer.dialpad.SmartDialController.LOG_TAG; - -import android.content.Context; -import android.content.SharedPreferences; -import android.database.Cursor; -import android.net.Uri; -import android.preference.PreferenceManager; -import android.provider.ContactsContract; -import android.provider.ContactsContract.CommonDataKinds.Phone; -import android.provider.ContactsContract.Contacts; -import android.provider.ContactsContract.Data; -import android.provider.ContactsContract.Directory; -import android.telephony.TelephonyManager; -import android.text.TextUtils; -import android.util.Log; - -import com.android.contacts.common.util.StopWatch; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; - -import java.util.Comparator; -import java.util.HashSet; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Cache object used to cache Smart Dial contacts that handles various states of the cache at the - * point in time when getContacts() is called - * 1) Cache is currently empty and there is no caching thread running - getContacts() starts a - * caching thread and returns the cache when completed - * 2) The cache is currently empty, but a caching thread has been started - getContacts() waits - * till the existing caching thread is completed before immediately returning the cache - * 3) The cache has already been populated, and there is no caching thread running - getContacts() - * returns the existing cache immediately - * 4) The cache has already been populated, but there is another caching thread running (due to - * a forced cache refresh due to content updates - getContacts() returns the existing cache - * immediately - */ -public class SmartDialCache { - - public static class ContactNumber { - public final String displayName; - public final String lookupKey; - public final long id; - public final int affinity; - public final String phoneNumber; - - public ContactNumber(long id, String displayName, String phoneNumber, String lookupKey, - int affinity) { - this.displayName = displayName; - this.lookupKey = lookupKey; - this.id = id; - this.affinity = affinity; - this.phoneNumber = phoneNumber; - } - } - - public static interface PhoneQuery { - - Uri URI = Phone.CONTENT_URI.buildUpon(). - appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, - String.valueOf(Directory.DEFAULT)). - appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true"). - build(); - - final String[] PROJECTION_PRIMARY = new String[] { - Phone._ID, // 0 - Phone.TYPE, // 1 - Phone.LABEL, // 2 - Phone.NUMBER, // 3 - Phone.CONTACT_ID, // 4 - Phone.LOOKUP_KEY, // 5 - Phone.DISPLAY_NAME_PRIMARY, // 6 - }; - - final String[] PROJECTION_ALTERNATIVE = new String[] { - Phone._ID, // 0 - Phone.TYPE, // 1 - Phone.LABEL, // 2 - Phone.NUMBER, // 3 - Phone.CONTACT_ID, // 4 - Phone.LOOKUP_KEY, // 5 - Phone.DISPLAY_NAME_ALTERNATIVE, // 6 - }; - - public static final int PHONE_ID = 0; - public static final int PHONE_TYPE = 1; - public static final int PHONE_LABEL = 2; - public static final int PHONE_NUMBER = 3; - public static final int PHONE_CONTACT_ID = 4; - public static final int PHONE_LOOKUP_KEY = 5; - public static final int PHONE_DISPLAY_NAME = 6; - - // Current contacts - those contacted within the last 3 days (in milliseconds) - final static long LAST_TIME_USED_CURRENT_MS = 3L * 24 * 60 * 60 * 1000; - - // Recent contacts - those contacted within the last 30 days (in milliseconds) - final static long LAST_TIME_USED_RECENT_MS = 30L * 24 * 60 * 60 * 1000; - - final static String TIME_SINCE_LAST_USED_MS = - "(? - " + Data.LAST_TIME_USED + ")"; - - final static String SORT_BY_DATA_USAGE = - "(CASE WHEN " + TIME_SINCE_LAST_USED_MS + " < " + LAST_TIME_USED_CURRENT_MS + - " THEN 0 " + - " WHEN " + TIME_SINCE_LAST_USED_MS + " < " + LAST_TIME_USED_RECENT_MS + - " THEN 1 " + - " ELSE 2 END), " + - Data.TIMES_USED + " DESC"; - - // This sort order is similar to that used by the ContactsProvider when returning a list - // of frequently called contacts. - public static final String SORT_ORDER = - Contacts.STARRED + " DESC, " - + Data.IS_SUPER_PRIMARY + " DESC, " - + SORT_BY_DATA_USAGE + ", " - + Contacts.IN_VISIBLE_GROUP + " DESC, " - + Contacts.DISPLAY_NAME + ", " - + Data.CONTACT_ID + ", " - + Data.IS_PRIMARY + " DESC"; - } - - // Static set used to determine which countries use NANP numbers - public static Set<String> sNanpCountries = null; - - private SmartDialTrie mContactsCache; - private static AtomicInteger mCacheStatus; - private final SmartDialMap mMap; - private final int mNameDisplayOrder; - private final Context mContext; - private final static Object mLock = new Object(); - - /** The country code of the user's sim card obtained by calling getSimCountryIso*/ - private static final String PREF_USER_SIM_COUNTRY_CODE = - "DialtactsActivity_user_sim_country_code"; - private static final String PREF_USER_SIM_COUNTRY_CODE_DEFAULT = null; - - private static String sUserSimCountryCode = PREF_USER_SIM_COUNTRY_CODE_DEFAULT; - private static boolean sUserInNanpRegion = false; - - public static final int CACHE_NEEDS_RECACHE = 1; - public static final int CACHE_IN_PROGRESS = 2; - public static final int CACHE_COMPLETED = 3; - - private static final boolean DEBUG = false; - - private SmartDialCache(Context context, int nameDisplayOrder, SmartDialMap map) { - mNameDisplayOrder = nameDisplayOrder; - mMap = map; - Preconditions.checkNotNull(context, "Context must not be null"); - mContext = context.getApplicationContext(); - mCacheStatus = new AtomicInteger(CACHE_NEEDS_RECACHE); - - final TelephonyManager manager = (TelephonyManager) context.getSystemService( - Context.TELEPHONY_SERVICE); - if (manager != null) { - sUserSimCountryCode = manager.getSimCountryIso(); - } - - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - if (sUserSimCountryCode != null) { - // Update shared preferences with the latest country obtained from getSimCountryIso - prefs.edit().putString(PREF_USER_SIM_COUNTRY_CODE, sUserSimCountryCode).apply(); - } else { - // Couldn't get the country from getSimCountryIso. Maybe we are in airplane mode. - // Try to load the settings, if any from SharedPreferences. - sUserSimCountryCode = prefs.getString(PREF_USER_SIM_COUNTRY_CODE, - PREF_USER_SIM_COUNTRY_CODE_DEFAULT); - } - - sUserInNanpRegion = isCountryNanp(sUserSimCountryCode); - - } - - private static SmartDialCache instance; - - /** - * Returns an instance of SmartDialCache. - * - * @param context A context that provides a valid ContentResolver. - * @param nameDisplayOrder One of the two name display order integer constants (1 or 2) as saved - * in settings under the key - * {@link android.provider.ContactsContract.Preferences#DISPLAY_ORDER}. - * @return An instance of SmartDialCache - */ - public static synchronized SmartDialCache getInstance(Context context, int nameDisplayOrder, - SmartDialMap map) { - if (instance == null) { - instance = new SmartDialCache(context, nameDisplayOrder, map); - } - return instance; - } - - /** - * Performs a database query, iterates through the returned cursor and saves the retrieved - * contacts to a local cache. - */ - private void cacheContacts(Context context) { - mCacheStatus.set(CACHE_IN_PROGRESS); - synchronized(mLock) { - if (DEBUG) { - Log.d(LOG_TAG, "Starting caching thread"); - } - final StopWatch stopWatch = DEBUG ? StopWatch.start("SmartDial Cache") : null; - final String millis = String.valueOf(System.currentTimeMillis()); - final Cursor c = context.getContentResolver().query(PhoneQuery.URI, - (mNameDisplayOrder == ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY) - ? PhoneQuery.PROJECTION_PRIMARY : PhoneQuery.PROJECTION_ALTERNATIVE, - null, new String[] {millis, millis}, - PhoneQuery.SORT_ORDER); - if (DEBUG) { - stopWatch.lap("SmartDial query complete"); - } - if (c == null) { - Log.w(LOG_TAG, "SmartDial query received null for cursor"); - if (DEBUG) { - stopWatch.stopAndLog("SmartDial query received null for cursor", 0); - } - mCacheStatus.getAndSet(CACHE_NEEDS_RECACHE); - return; - } - final SmartDialTrie cache = new SmartDialTrie(mMap, sUserInNanpRegion); - try { - c.moveToPosition(-1); - int affinityCount = 0; - while (c.moveToNext()) { - final String displayName = c.getString(PhoneQuery.PHONE_DISPLAY_NAME); - final String phoneNumber = c.getString(PhoneQuery.PHONE_NUMBER); - final long id = c.getLong(PhoneQuery.PHONE_CONTACT_ID); - final String lookupKey = c.getString(PhoneQuery.PHONE_LOOKUP_KEY); - cache.put(new ContactNumber(id, displayName, phoneNumber, lookupKey, - affinityCount)); - affinityCount++; - } - } finally { - c.close(); - mContactsCache = cache; - if (DEBUG) { - stopWatch.stopAndLog("SmartDial caching completed", 0); - } - } - } - if (DEBUG) { - Log.d(LOG_TAG, "Caching thread completed"); - } - mCacheStatus.getAndSet(CACHE_COMPLETED); - } - - /** - * Returns the list of cached contacts. This is blocking so it should not be called from the UI - * thread. There are 4 possible scenarios: - * - * 1) Cache is currently empty and there is no caching thread running - getContacts() starts a - * caching thread and returns the cache when completed - * 2) The cache is currently empty, but a caching thread has been started - getContacts() waits - * till the existing caching thread is completed before immediately returning the cache - * 3) The cache has already been populated, and there is no caching thread running - - * getContacts() returns the existing cache immediately - * 4) The cache has already been populated, but there is another caching thread running (due to - * a forced cache refresh due to content updates - getContacts() returns the existing cache - * immediately - * - * @return List of already cached contacts, or an empty list if the caching failed for any - * reason. - */ - public SmartDialTrie getContacts() { - // Either scenario 3 or 4 - This means just go ahead and return the existing cache - // immediately even if there is a caching thread currently running. We are guaranteed to - // have the newest value of mContactsCache at this point because it is volatile. - if (mContactsCache != null) { - return mContactsCache; - } - // At this point we are forced to wait for cacheContacts to complete in another thread(if - // one currently exists) because of mLock. - synchronized(mLock) { - // If mContactsCache is still null at this point, either there was never any caching - // process running, or it failed (Scenario 1). If so, just go ahead and try to cache - // the contacts again. - if (mContactsCache == null) { - cacheContacts(mContext); - return (mContactsCache == null) ? new SmartDialTrie() : mContactsCache; - } else { - // After waiting for the lock on mLock to be released, mContactsCache is now - // non-null due to the completion of the caching thread (Scenario 2). Go ahead - // and return the existing cache. - return mContactsCache; - } - } - } - - /** - * Cache contacts only if there is a need to (forced cache refresh or no attempt to cache yet). - * This method is called in 2 places: whenever the DialpadFragment comes into view, and in - * onResume. - * - * @param forceRecache If true, force a cache refresh. - */ - - public void cacheIfNeeded(boolean forceRecache) { - if (DEBUG) { - Log.d("SmartDial", "cacheIfNeeded called with " + String.valueOf(forceRecache)); - } - if (mCacheStatus.get() == CACHE_IN_PROGRESS) { - return; - } - if (forceRecache || mCacheStatus.get() == CACHE_NEEDS_RECACHE) { - // Because this method can be possibly be called multiple times in rapid succession, - // set the cache status even before starting a caching thread to avoid unnecessarily - // spawning extra threads. - mCacheStatus.set(CACHE_IN_PROGRESS); - startCachingThread(); - } - } - - private void startCachingThread() { - new Thread(new Runnable() { - @Override - public void run() { - cacheContacts(mContext); - } - }).start(); - } - - public static class ContactAffinityComparator implements Comparator<ContactNumber> { - @Override - public int compare(ContactNumber lhs, ContactNumber rhs) { - // Smaller affinity is better because they are numbered in ascending order in - // the order the contacts were returned from the ContactsProvider (sorted by - // frequency of use and time last used - return Integer.compare(lhs.affinity, rhs.affinity); - } - - } - - public SmartDialMap getMap() { - return mMap; - } - - public boolean getUserInNanpRegion() { - return sUserInNanpRegion; - } - - /** - * Indicates whether the given country uses NANP numbers - * - * @param country ISO 3166 country code (case doesn't matter) - * @return True if country uses NANP numbers (e.g. US, Canada), false otherwise - */ - @VisibleForTesting - static boolean isCountryNanp(String country) { - if (TextUtils.isEmpty(country)) { - return false; - } - if (sNanpCountries == null) { - sNanpCountries = initNanpCountries(); - } - return sNanpCountries.contains(country.toUpperCase()); - } - - private static Set<String> initNanpCountries() { - final HashSet<String> result = new HashSet<String>(); - result.add("US"); // United States - result.add("CA"); // Canada - result.add("AS"); // American Samoa - result.add("AI"); // Anguilla - result.add("AG"); // Antigua and Barbuda - result.add("BS"); // Bahamas - result.add("BB"); // Barbados - result.add("BM"); // Bermuda - result.add("VG"); // British Virgin Islands - result.add("KY"); // Cayman Islands - result.add("DM"); // Dominica - result.add("DO"); // Dominican Republic - result.add("GD"); // Grenada - result.add("GU"); // Guam - result.add("JM"); // Jamaica - result.add("PR"); // Puerto Rico - result.add("MS"); // Montserrat - result.add("MP"); // Northern Mariana Islands - result.add("KN"); // Saint Kitts and Nevis - result.add("LC"); // Saint Lucia - result.add("VC"); // Saint Vincent and the Grenadines - result.add("TT"); // Trinidad and Tobago - result.add("TC"); // Turks and Caicos Islands - result.add("VI"); // U.S. Virgin Islands - return result; - } -} diff --git a/src/com/android/dialer/dialpad/SmartDialLoaderTask.java b/src/com/android/dialer/dialpad/SmartDialLoaderTask.java index d584c1793..71cbfa27d 100644 --- a/src/com/android/dialer/dialpad/SmartDialLoaderTask.java +++ b/src/com/android/dialer/dialpad/SmartDialLoaderTask.java @@ -18,24 +18,21 @@ package com.android.dialer.dialpad; import static com.android.dialer.dialpad.SmartDialController.LOG_TAG; +import android.content.Context; import android.os.AsyncTask; import android.provider.ContactsContract; import android.provider.ContactsContract.Contacts; import android.telephony.PhoneNumberUtils; -import android.util.Log; import com.android.contacts.common.preference.ContactsPreferences; import com.android.contacts.common.util.StopWatch; -import com.android.dialer.dialpad.SmartDialCache.ContactNumber; +import com.android.dialer.database.DialerDatabaseHelper; +import com.android.dialer.database.DialerDatabaseHelper.ContactNumber; -import com.google.common.base.Objects; import com.google.common.collect.Lists; import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; import java.util.List; -import java.util.Set; /** * This task searches through the provided cache to return the top 3 contacts(ranked by confidence) @@ -50,28 +47,20 @@ public class SmartDialLoaderTask extends AsyncTask<String, Integer, List<SmartDi static private final boolean DEBUG = false; - private static final int MAX_ENTRIES = 3; - - private final SmartDialCache mContactsCache; - private final SmartDialLoaderCallback mCallback; + private final DialerDatabaseHelper mDialerDatabaseHelper; + private final String mQuery; - /** - * See {@link ContactsPreferences#getDisplayOrder()}. - * {@link ContactsContract.Preferences#DISPLAY_ORDER_PRIMARY} (first name first) - * {@link ContactsContract.Preferences#DISPLAY_ORDER_ALTERNATIVE} (last name first) - */ private final SmartDialNameMatcher mNameMatcher; - public SmartDialLoaderTask(SmartDialLoaderCallback callback, String query, - SmartDialCache cache) { + public SmartDialLoaderTask(SmartDialLoaderCallback callback, String query, Context context) { this.mCallback = callback; - this.mContactsCache = cache; - this.mNameMatcher = new SmartDialNameMatcher(PhoneNumberUtils.normalizeNumber(query), - cache.getMap()); + mDialerDatabaseHelper = DialerDatabaseHelper.getInstance(context); this.mQuery = query; + this.mNameMatcher = new SmartDialNameMatcher(PhoneNumberUtils.normalizeNumber(query), + SmartDialPrefix.getMap()); } @Override @@ -87,85 +76,33 @@ public class SmartDialLoaderTask extends AsyncTask<String, Integer, List<SmartDi } /** - * Loads all visible contacts with phone numbers and check if their display names match the - * query. Return at most {@link #MAX_ENTRIES} {@link SmartDialEntry}'s for the matching - * contacts. + * Loads top visible contacts with phone numbers and check if their display names match the + * query. */ private ArrayList<SmartDialEntry> getContactMatches() { - final SmartDialTrie trie = mContactsCache.getContacts(); - final boolean matchNanp = mContactsCache.getUserInNanpRegion(); - - if (DEBUG) { - Log.d(LOG_TAG, "Size of cache: " + trie.size()); - } - final StopWatch stopWatch = DEBUG ? StopWatch.start("Start Match") : null; - final ArrayList<ContactNumber> allMatches = trie.getAllWithPrefix(mNameMatcher.getQuery()); + + final ArrayList<ContactNumber> allMatches = mDialerDatabaseHelper.getLooseMatches(mQuery, + mNameMatcher); if (DEBUG) { stopWatch.lap("Find matches"); } - // Sort matches in order of ascending contact affinity (lower is better) - Collections.sort(allMatches, new SmartDialCache.ContactAffinityComparator()); - if (DEBUG) { - stopWatch.lap("Sort"); - } - final Set<ContactMatch> duplicates = new HashSet<ContactMatch>(); + final ArrayList<SmartDialEntry> candidates = Lists.newArrayList(); for (ContactNumber contact : allMatches) { - final ContactMatch contactMatch = new ContactMatch(contact.lookupKey, contact.id); - // Don't add multiple contact numbers from the same contact into suggestions if - // there are multiple matches. Instead, just keep the highest priority number - // instead. - if (duplicates.contains(contactMatch)) { - continue; - } - duplicates.add(contactMatch); final boolean matches = mNameMatcher.matches(contact.displayName); - candidates.add(new SmartDialEntry( contact.displayName, Contacts.getLookupUri(contact.id, contact.lookupKey), contact.phoneNumber, mNameMatcher.getMatchPositions(), - mNameMatcher.matchesNumber(contact.phoneNumber, - mNameMatcher.getQuery(), matchNanp) + mNameMatcher.matchesNumber(contact.phoneNumber, mNameMatcher.getQuery()) )); - if (candidates.size() >= MAX_ENTRIES) { - break; - } } if (DEBUG) { stopWatch.stopAndLog(LOG_TAG + " Match Complete", 0); } return candidates; } - - private class ContactMatch { - public final String lookupKey; - public final long id; - - public ContactMatch(String lookupKey, long id) { - this.lookupKey = lookupKey; - this.id = id; - } - - @Override - public int hashCode() { - return Objects.hashCode(lookupKey, id); - } - - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - if (object instanceof ContactMatch) { - ContactMatch that = (ContactMatch) object; - return Objects.equal(this.lookupKey, that.lookupKey) - && Objects.equal(this.id, that.id); - } - return false; - } - } } diff --git a/src/com/android/dialer/dialpad/SmartDialNameMatcher.java b/src/com/android/dialer/dialpad/SmartDialNameMatcher.java index d7d5ad523..fe88e930d 100644 --- a/src/com/android/dialer/dialpad/SmartDialNameMatcher.java +++ b/src/com/android/dialer/dialpad/SmartDialNameMatcher.java @@ -18,7 +18,7 @@ package com.android.dialer.dialpad; import android.text.TextUtils; -import com.android.dialer.dialpad.SmartDialTrie.CountryCodeWithOffset; +import com.android.dialer.dialpad.SmartDialPrefix.PhoneNumberTokens; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Lists; @@ -88,40 +88,51 @@ public class SmartDialNameMatcher { } /** - * Matches a phone number against a query, taking care of formatting characters and also - * taking into account country code prefixes and special NANP number treatment. + * Matches a phone number against a query. Let the test application overwrite the NANP setting. * * @param phoneNumber - Raw phone number * @param query - Normalized query (only contains numbers from 0-9) - * @param matchNanp - Whether or not to do special matching for NANP numbers + * @param useNanp - Overwriting nanp setting boolean, used for testing. * @return {@literal null} if the number and the query don't match, a valid * SmartDialMatchPosition with the matching positions otherwise */ - public SmartDialMatchPosition matchesNumber(String phoneNumber, String query, - boolean matchNanp) { + @VisibleForTesting + public SmartDialMatchPosition matchesNumber(String phoneNumber, String query, boolean useNanp) { // Try matching the number as is SmartDialMatchPosition matchPos = matchesNumberWithOffset(phoneNumber, query, 0); if (matchPos == null) { - // Try matching the number without the '+' prefix, if any - final CountryCodeWithOffset code = SmartDialTrie.getOffsetWithoutCountryCode( - phoneNumber); - if (code != null) { - matchPos = matchesNumberWithOffset(phoneNumber, query, code.offset); + final PhoneNumberTokens phoneNumberTokens = + SmartDialPrefix.parsePhoneNumber(phoneNumber); + + if (phoneNumberTokens == null) { + return matchPos; } - if (matchPos == null && matchNanp) { - // Try matching NANP numbers - final int[] offsets = SmartDialTrie.getOffsetForNANPNumbers(phoneNumber, - mMap); - for (int i = 0; i < offsets.length; i++) { - matchPos = matchesNumberWithOffset(phoneNumber, query, offsets[i]); - if (matchPos != null) break; - } + if (phoneNumberTokens.countryCodeOffset != 0) { + matchPos = matchesNumberWithOffset(phoneNumber, query, + phoneNumberTokens.countryCodeOffset); + } + if (matchPos == null && phoneNumberTokens.nanpCodeOffset != 0 && useNanp) { + matchPos = matchesNumberWithOffset(phoneNumber, query, + phoneNumberTokens.nanpCodeOffset); } } return matchPos; } /** + * Matches a phone number against a query, taking care of formatting characters and also + * taking into account country code prefixes and special NANP number treatment. + * + * @param phoneNumber - Raw phone number + * @param query - Normalized query (only contains numbers from 0-9) + * @return {@literal null} if the number and the query don't match, a valid + * SmartDialMatchPosition with the matching positions otherwise + */ + public SmartDialMatchPosition matchesNumber(String phoneNumber, String query) { + return matchesNumber(phoneNumber, query, true); + } + + /** * Matches a phone number against a query, taking care of formatting characters * * @param phoneNumber - Raw phone number diff --git a/src/com/android/dialer/dialpad/SmartDialPrefix.java b/src/com/android/dialer/dialpad/SmartDialPrefix.java new file mode 100644 index 000000000..857f6408a --- /dev/null +++ b/src/com/android/dialer/dialpad/SmartDialPrefix.java @@ -0,0 +1,608 @@ +/* + * Copyright (C) 2013 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.dialer.dialpad; + +import android.content.Context; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.telephony.TelephonyManager; +import android.text.TextUtils; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +/** + * Smart Dial utility class to find prefixes of contacts. It contains both methods to find supported + * prefix combinations for contact names, and also methods to find supported prefix combinations for + * contacts' phone numbers. Each contact name is separated into several tokens, such as first name, + * middle name, family name etc. Each phone number is also separated into country code, NANP area + * code, and local number if such separation is possible. + */ +public class SmartDialPrefix { + + /** The number of starting and ending tokens in a contact's name considered for initials. + * For example, if both constants are set to 2, and a contact's name is + * "Albert Ben Charles Daniel Ed Foster", the first two tokens "Albert" "Ben", and last two + * tokens "Ed" "Foster" can be replaced by their initials in contact name matching. + * Users can look up this contact by combinations of his initials such as "AF" "BF" "EF" "ABF" + * "BEF" "ABEF" etc, but can not use combinations such as "CF" "DF" "ACF" "ADF" etc. + */ + private static final int LAST_TOKENS_FOR_INITIALS = 2; + private static final int FIRST_TOKENS_FOR_INITIALS = 2; + + /** The country code of the user's sim card obtained by calling getSimCountryIso*/ + private static final String PREF_USER_SIM_COUNTRY_CODE = + "DialtactsActivity_user_sim_country_code"; + private static final String PREF_USER_SIM_COUNTRY_CODE_DEFAULT = null; + private static String sUserSimCountryCode = PREF_USER_SIM_COUNTRY_CODE_DEFAULT; + + /** Indicates whether user is in NANP regions.*/ + private static boolean sUserInNanpRegion = false; + + /** Set of country names that use NANP code.*/ + private static Set<String> sNanpCountries = null; + + /** Set of supported country codes in front of the phone number. */ + private static Set<String> sCountryCodes = null; + + /** Dialpad mapping. */ + private static final SmartDialMap mMap = new LatinSmartDialMap(); + + private static boolean sNanpInitialized = false; + + /** Initializes the Nanp settings, and finds out whether user is in a NANP region.*/ + public static void initializeNanpSettings(Context context){ + final TelephonyManager manager = (TelephonyManager) context.getSystemService( + Context.TELEPHONY_SERVICE); + if (manager != null) { + sUserSimCountryCode = manager.getSimCountryIso(); + } + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + if (sUserSimCountryCode != null) { + /** Updates shared preferences with the latest country obtained from getSimCountryIso.*/ + prefs.edit().putString(PREF_USER_SIM_COUNTRY_CODE, sUserSimCountryCode).apply(); + } else { + /** Uses previously stored country code if loading fails. */ + sUserSimCountryCode = prefs.getString(PREF_USER_SIM_COUNTRY_CODE, + PREF_USER_SIM_COUNTRY_CODE_DEFAULT); + } + /** Queries the NANP country list to find out whether user is in a NANP region.*/ + sUserInNanpRegion = isCountryNanp(sUserSimCountryCode); + sNanpInitialized = true; + } + + /** + * Explicitly setting the user Nanp to the given boolean + */ + @VisibleForTesting + public static void setUserInNanpRegion(boolean userInNanpRegion) { + sUserInNanpRegion = userInNanpRegion; + } + + /** + * Class to record phone number parsing information. + */ + public static class PhoneNumberTokens { + /** Country code of the phone number. */ + final String countryCode; + + /** Offset of national number after the country code. */ + final int countryCodeOffset; + + /** Offset of local number after NANP area code.*/ + final int nanpCodeOffset; + + public PhoneNumberTokens(String countryCode, int countryCodeOffset, int nanpCodeOffset) { + this.countryCode = countryCode; + this.countryCodeOffset = countryCodeOffset; + this.nanpCodeOffset = nanpCodeOffset; + } + } + + /** + * Parses a contact's name into a list of separated tokens. + * + * @param contactName Contact's name stored in string. + * @return A list of name tokens, for example separated first names, last name, etc. + */ + public static ArrayList<String> parseToIndexTokens(String contactName) { + final int length = contactName.length(); + final ArrayList<String> result = Lists.newArrayList(); + char c; + final StringBuilder currentIndexToken = new StringBuilder(); + /** + * Iterates through the whole name string. If the current character is a valid character, + * append it to the current token. If the current character is not a valid character, for + * example space " ", mark the current token as complete and add it to the list of tokens. + */ + for (int i = 0; i < length; i++) { + c = mMap.normalizeCharacter(contactName.charAt(i)); + if (mMap.isValidDialpadCharacter(c)) { + /** Converts a character into the number on dialpad that represents the character.*/ + currentIndexToken.append(mMap.getDialpadIndex(c)); + } else { + if (currentIndexToken.length() != 0) { + result.add(currentIndexToken.toString()); + } + currentIndexToken.delete(0, currentIndexToken.length()); + } + } + + /** Adds the last token in case it has not been added.*/ + if (currentIndexToken.length() != 0) { + result.add(currentIndexToken.toString()); + } + return result; + } + + /** + * Generates a list of strings that any prefix of any string in the list can be used to look + * up the contact's name. + * + * @param index The contact's name in string. + * @return A List of strings, whose prefix can be used to look up the contact. + */ + public static ArrayList<String> generateNamePrefixes(String index) { + final ArrayList<String> result = Lists.newArrayList(); + + /** Parses the name into a list of tokens.*/ + final ArrayList<String> indexTokens = parseToIndexTokens(index); + + if (indexTokens.size() > 0) { + /** Adds the full token combinations to the list. For example, a contact with name + * "Albert Ben Ed Foster" can be looked up by any prefix of the following strings + * "Foster" "EdFoster" "BenEdFoster" and "AlbertBenEdFoster". This covers all cases of + * look up that contains only one token, and that spans multiple continuous tokens. + */ + final StringBuilder fullNameToken = new StringBuilder(); + for (int i = indexTokens.size() - 1; i >= 0; i--) { + fullNameToken.insert(0, indexTokens.get(i)); + result.add(fullNameToken.toString()); + } + + /** Adds initial combinations to the list, with the number of initials restricted by + * {@link #LAST_TOKENS_FOR_INITIALS} and {@link #FIRST_TOKENS_FOR_INITIALS}. + * For example, a contact with name "Albert Ben Ed Foster" can be looked up by any + * prefix of the following strings "EFoster" "BFoster" "BEFoster" "AFoster" "ABFoster" + * "AEFoster" and "ABEFoster". This covers all cases of initial lookup. + */ + ArrayList<String> fullNames = Lists.newArrayList(); + fullNames.add(indexTokens.get(indexTokens.size() - 1)); + final int recursiveNameStart = result.size(); + int recursiveNameEnd = result.size(); + String initial = ""; + for (int i = indexTokens.size() - 2; i >= 0; i--) { + if ((i >= indexTokens.size() - LAST_TOKENS_FOR_INITIALS) || + (i < FIRST_TOKENS_FOR_INITIALS)) { + initial = indexTokens.get(i).substring(0, 1); + + /** Recursively adds initial combinations to the list.*/ + for (int j = 0; j < fullNames.size(); ++j) { + result.add(initial + fullNames.get(j)); + } + for (int j = recursiveNameStart; j < recursiveNameEnd; ++j) { + result.add(initial + result.get(j)); + } + recursiveNameEnd = result.size(); + final String currentFullName = fullNames.get(fullNames.size() - 1); + fullNames.add(indexTokens.get(i) + currentFullName); + } + } + } + + return result; + } + + /** + * Computes a list of number strings based on tokens of a given phone number. Any prefix + * of any string in the list can be used to look up the phone number. The list include the + * full phone number, the national number if there is a country code in the phone number, and + * the local number if there is an area code in the phone number following the NANP format. + * For example, if a user has phone number +41 71 394 8392, the list will contain 41713948392 + * and 713948392. Any prefix to either of the strings can be used to look up the phone number. + * If a user has a phone number +1 555-302-3029 (NANP format), the list will contain + * 15553023029, 5553023029, and 3023029. + * + * @param number String of user's phone number. + * @return A list of strings where any prefix of any entry can be used to look up the number. + */ + public static ArrayList<String> parseToNumberTokens(String number) { + final ArrayList<String> result = Lists.newArrayList(); + if (!TextUtils.isEmpty(number)) { + /** Adds the full number to the list.*/ + result.add(SmartDialNameMatcher.normalizeNumber(number, mMap)); + + final PhoneNumberTokens phoneNumberTokens = parsePhoneNumber(number); + if (phoneNumberTokens == null) { + return result; + } + + if (phoneNumberTokens.countryCodeOffset != 0) { + result.add(SmartDialNameMatcher.normalizeNumber(number, + phoneNumberTokens.countryCodeOffset, mMap)); + } + + if (phoneNumberTokens.nanpCodeOffset != 0) { + result.add(SmartDialNameMatcher.normalizeNumber(number, + phoneNumberTokens.nanpCodeOffset, mMap)); + } + } + return result; + } + + /** + * Parses a phone number to find out whether it has country code and NANP area code. + * + * @param number Raw phone number. + * @return a PhoneNumberToken instance with country code, NANP code information. + */ + public static PhoneNumberTokens parsePhoneNumber(String number) { + String countryCode = ""; + int countryCodeOffset = 0; + int nanpNumberOffset = 0; + + if (!TextUtils.isEmpty(number)) { + String normalizedNumber = SmartDialNameMatcher.normalizeNumber(number, mMap); + if (number.charAt(0) == '+') { + /** If the number starts with '+', tries to find valid country code. */ + for (int i = 1; i <= 1 + 3; i++) { + if (number.length() <= i) { + break; + } + countryCode = number.substring(1, i); + if (isValidCountryCode(countryCode)) { + countryCodeOffset = i; + break; + } + } + } else { + /** If the number does not start with '+', finds out whether it is in NANP + * format and has '1' preceding the number. + */ + if ((normalizedNumber.charAt(0) == '1') && (normalizedNumber.length() == 11) && + (sUserInNanpRegion)) { + countryCode = "1"; + countryCodeOffset = number.indexOf(normalizedNumber.charAt(1)); + if (countryCodeOffset == -1) { + countryCodeOffset = 0; + } + } + } + + /** If user is in NANP region, finds out whether a number is in NANP format.*/ + if (sUserInNanpRegion) { + String areaCode = ""; + if (countryCode.equals("") && normalizedNumber.length() == 10){ + /** if the number has no country code but fits the NANP format, extracts the + * NANP area code, and finds out offset of the local number. + */ + areaCode = normalizedNumber.substring(0, 3); + } else if (countryCode.equals("1") && normalizedNumber.length() == 11) { + /** If the number has country code '1', finds out area code and offset of the + * local number. + */ + areaCode = normalizedNumber.substring(1, 4); + } + if (!areaCode.equals("")) { + final int areaCodeIndex = number.indexOf(areaCode); + if (areaCodeIndex != -1) { + nanpNumberOffset = number.indexOf(areaCode) + 3; + } + } + } + } + return new PhoneNumberTokens(countryCode, countryCodeOffset, nanpNumberOffset); + } + + /** + * Checkes whether a country code is valid. + */ + private static boolean isValidCountryCode(String countryCode) { + if (sCountryCodes == null) { + sCountryCodes = initCountryCodes(); + } + return sCountryCodes.contains(countryCode); + } + + private static Set<String> initCountryCodes() { + final HashSet<String> result = new HashSet<String>(); + result.add("1"); + result.add("7"); + result.add("20"); + result.add("27"); + result.add("30"); + result.add("31"); + result.add("32"); + result.add("33"); + result.add("34"); + result.add("36"); + result.add("39"); + result.add("40"); + result.add("41"); + result.add("43"); + result.add("44"); + result.add("45"); + result.add("46"); + result.add("47"); + result.add("48"); + result.add("49"); + result.add("51"); + result.add("52"); + result.add("53"); + result.add("54"); + result.add("55"); + result.add("56"); + result.add("57"); + result.add("58"); + result.add("60"); + result.add("61"); + result.add("62"); + result.add("63"); + result.add("64"); + result.add("65"); + result.add("66"); + result.add("81"); + result.add("82"); + result.add("84"); + result.add("86"); + result.add("90"); + result.add("91"); + result.add("92"); + result.add("93"); + result.add("94"); + result.add("95"); + result.add("98"); + result.add("211"); + result.add("212"); + result.add("213"); + result.add("216"); + result.add("218"); + result.add("220"); + result.add("221"); + result.add("222"); + result.add("223"); + result.add("224"); + result.add("225"); + result.add("226"); + result.add("227"); + result.add("228"); + result.add("229"); + result.add("230"); + result.add("231"); + result.add("232"); + result.add("233"); + result.add("234"); + result.add("235"); + result.add("236"); + result.add("237"); + result.add("238"); + result.add("239"); + result.add("240"); + result.add("241"); + result.add("242"); + result.add("243"); + result.add("244"); + result.add("245"); + result.add("246"); + result.add("247"); + result.add("248"); + result.add("249"); + result.add("250"); + result.add("251"); + result.add("252"); + result.add("253"); + result.add("254"); + result.add("255"); + result.add("256"); + result.add("257"); + result.add("258"); + result.add("260"); + result.add("261"); + result.add("262"); + result.add("263"); + result.add("264"); + result.add("265"); + result.add("266"); + result.add("267"); + result.add("268"); + result.add("269"); + result.add("290"); + result.add("291"); + result.add("297"); + result.add("298"); + result.add("299"); + result.add("350"); + result.add("351"); + result.add("352"); + result.add("353"); + result.add("354"); + result.add("355"); + result.add("356"); + result.add("357"); + result.add("358"); + result.add("359"); + result.add("370"); + result.add("371"); + result.add("372"); + result.add("373"); + result.add("374"); + result.add("375"); + result.add("376"); + result.add("377"); + result.add("378"); + result.add("379"); + result.add("380"); + result.add("381"); + result.add("382"); + result.add("385"); + result.add("386"); + result.add("387"); + result.add("389"); + result.add("420"); + result.add("421"); + result.add("423"); + result.add("500"); + result.add("501"); + result.add("502"); + result.add("503"); + result.add("504"); + result.add("505"); + result.add("506"); + result.add("507"); + result.add("508"); + result.add("509"); + result.add("590"); + result.add("591"); + result.add("592"); + result.add("593"); + result.add("594"); + result.add("595"); + result.add("596"); + result.add("597"); + result.add("598"); + result.add("599"); + result.add("670"); + result.add("672"); + result.add("673"); + result.add("674"); + result.add("675"); + result.add("676"); + result.add("677"); + result.add("678"); + result.add("679"); + result.add("680"); + result.add("681"); + result.add("682"); + result.add("683"); + result.add("685"); + result.add("686"); + result.add("687"); + result.add("688"); + result.add("689"); + result.add("690"); + result.add("691"); + result.add("692"); + result.add("800"); + result.add("808"); + result.add("850"); + result.add("852"); + result.add("853"); + result.add("855"); + result.add("856"); + result.add("870"); + result.add("878"); + result.add("880"); + result.add("881"); + result.add("882"); + result.add("883"); + result.add("886"); + result.add("888"); + result.add("960"); + result.add("961"); + result.add("962"); + result.add("963"); + result.add("964"); + result.add("965"); + result.add("966"); + result.add("967"); + result.add("968"); + result.add("970"); + result.add("971"); + result.add("972"); + result.add("973"); + result.add("974"); + result.add("975"); + result.add("976"); + result.add("977"); + result.add("979"); + result.add("992"); + result.add("993"); + result.add("994"); + result.add("995"); + result.add("996"); + result.add("998"); + return result; + } + + public static SmartDialMap getMap() { + return mMap; + } + + /** + * Indicates whether the given country uses NANP numbers + * @see <a href="https://en.wikipedia.org/wiki/North_American_Numbering_Plan"> + * https://en.wikipedia.org/wiki/North_American_Numbering_Plan</a> + * + * @param country ISO 3166 country code (case doesn't matter) + * @return True if country uses NANP numbers (e.g. US, Canada), false otherwise + */ + @VisibleForTesting + public static boolean isCountryNanp(String country) { + if (TextUtils.isEmpty(country)) { + return false; + } + if (sNanpCountries == null) { + sNanpCountries = initNanpCountries(); + } + return sNanpCountries.contains(country.toUpperCase()); + } + + private static Set<String> initNanpCountries() { + final HashSet<String> result = new HashSet<String>(); + result.add("US"); // United States + result.add("CA"); // Canada + result.add("AS"); // American Samoa + result.add("AI"); // Anguilla + result.add("AG"); // Antigua and Barbuda + result.add("BS"); // Bahamas + result.add("BB"); // Barbados + result.add("BM"); // Bermuda + result.add("VG"); // British Virgin Islands + result.add("KY"); // Cayman Islands + result.add("DM"); // Dominica + result.add("DO"); // Dominican Republic + result.add("GD"); // Grenada + result.add("GU"); // Guam + result.add("JM"); // Jamaica + result.add("PR"); // Puerto Rico + result.add("MS"); // Montserrat + result.add("MP"); // Northern Mariana Islands + result.add("KN"); // Saint Kitts and Nevis + result.add("LC"); // Saint Lucia + result.add("VC"); // Saint Vincent and the Grenadines + result.add("TT"); // Trinidad and Tobago + result.add("TC"); // Turks and Caicos Islands + result.add("VI"); // U.S. Virgin Islands + return result; + } + + /** + * Returns whether the user is in a region that uses Nanp format based on the sim location. + * + * @return Whether user is in Nanp region. + */ + public static boolean getUserInNanpRegion() { + return sUserInNanpRegion; + } +} diff --git a/src/com/android/dialer/dialpad/SmartDialTrie.java b/src/com/android/dialer/dialpad/SmartDialTrie.java deleted file mode 100644 index c62210b17..000000000 --- a/src/com/android/dialer/dialpad/SmartDialTrie.java +++ /dev/null @@ -1,671 +0,0 @@ -/* - * Copyright (C) 2013 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.dialer.dialpad; - -import android.text.TextUtils; - -import com.android.dialer.dialpad.SmartDialCache.ContactNumber; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.Set; - -/** - * Prefix trie where the only allowed characters are the characters '0' to '9'. Multiple contacts - * can occupy the same nodes. - * - * <p>Provides functions to get all contacts that lie on or below a node. - * This is useful for retrieving all contacts that start with that prefix.</p> - * - * <p>Also contains special logic to handle NANP numbers in the case that the user is from a region - * that uses NANP numbers.</p> - */ -public class SmartDialTrie { - @VisibleForTesting - static class ParseInfo { - byte[] indexes; - int nthFirstTokenPos; - int nthLastTokenPos; - } - - /** - * A country code and integer offset pair that represents the parsed country code in a - * phone number. The country code is a string containing the numeric country-code prefix in - * a phone number (e.g. 1 or 852). The offset is the integer position of where the country code - * ends in a phone number. - */ - public static class CountryCodeWithOffset { - public static final CountryCodeWithOffset NO_COUNTRY_CODE = new CountryCodeWithOffset(0, - ""); - - final String countryCode; - final int offset; - - public CountryCodeWithOffset(int offset, String countryCode) { - this.countryCode = countryCode; - this.offset = offset; - } - } - - final Node mRoot = new Node(); - private int mSize = 0; - private final SmartDialMap mMap; - private final boolean mFormatNanp; - - private static final int LAST_TOKENS_FOR_INITIALS = 2; - private static final int FIRST_TOKENS_FOR_INITIALS = 2; - - // Static set of all possible country codes in the world - public static Set<String> sCountryCodes = null; - - public SmartDialTrie() { - // Use the latin letter to digit map by default if none provided - this(new LatinSmartDialMap(), false); - } - - /** - * Creates a new SmartDialTrie. - * - * @param formatNanp True if inserted numbers are to be treated as NANP numbers - * such that numbers are automatically broken up by country prefix and area code. - */ - @VisibleForTesting - public SmartDialTrie(boolean formatNanp) { - this(new LatinSmartDialMap(), formatNanp); - } - - /** - * Creates a new SmartDialTrie. - * - * @param charMap Mapping of characters to digits to use when inserting names into the trie. - * @param formatNanp True if inserted numbers are to be treated as NANP numbers - * such that numbers are automatically broken up by country prefix and area code. - */ - public SmartDialTrie(SmartDialMap map, boolean formatNanp) { - mMap = map; - mFormatNanp = formatNanp; - } - - /** - * Returns all contacts in the prefix tree that correspond to this prefix. - */ - public ArrayList<ContactNumber> getAllWithPrefix(CharSequence prefix) { - final ArrayList<ContactNumber> result = Lists.newArrayList(); - if (TextUtils.isEmpty(prefix)) { - return result; - } - Node current = mRoot; - for (int i = 0; i < prefix.length(); i++) { - char ch = prefix.charAt(i); - current = current.getChild(ch, false); - if (current == null) { - return result; - } - } - // return all contacts that correspond to this prefix - getAll(current, result); - return result; - } - - /** - * Returns all the contacts located at and under the provided node(including its children) - */ - private void getAll(Node root, ArrayList<ContactNumber> output) { - if (root == null) { - return; - } - if (root.getContents() != null) { - output.addAll(root.getContents()); - } - for (int i = 0; i < root.getChildrenSize(); i++) { - getAll(root.getChild(i, false), output); - } - } - - /** - * Adds the display name and phone number of a contact into the prefix trie. - * - * @param contact Desired contact to add - */ - public void put(ContactNumber contact) { - // Preconvert the display name into a byte array containing indexes to avoid having to - // remap each character over multiple passes - final ParseInfo info = parseToIndexes(contact.displayName, FIRST_TOKENS_FOR_INITIALS, - LAST_TOKENS_FOR_INITIALS); - putForPrefix(contact, mRoot, info, 0, true); - // We don't need to do the same for phone numbers since we only make one pass over them. - // Strip the calling code from the phone number here - if (!TextUtils.isEmpty(contact.phoneNumber)) { - // Handle country codes for numbers with a + prefix - final CountryCodeWithOffset code = getOffsetWithoutCountryCode(contact.phoneNumber); - if (code.offset != 0) { - putNumber(contact, contact.phoneNumber, code.offset); - } - if ((code.countryCode.equals("1") || code.offset == 0) && mFormatNanp) { - // Special case handling for NANP numbers (1-xxx-xxx-xxxx) - final String stripped = SmartDialNameMatcher.normalizeNumber( - contact.phoneNumber, code.offset, mMap); - if (!TextUtils.isEmpty(stripped)) { - int trunkPrefixOffset = 0; - if (stripped.charAt(0) == '1') { - // If the number starts with 1, we can assume its the trunk prefix. - trunkPrefixOffset = 1; - } - if (stripped.length() == (10 + trunkPrefixOffset)) { - // Valid NANP number - if (trunkPrefixOffset != 0) { - // Add the digits that follow the 1st digit (trunk prefix) - // If trunkPrefixOffset is 0, we will add the number as is anyway, so - // don't bother. - putNumber(contact, stripped, trunkPrefixOffset); - } - // Add the digits that follow the next 3 digits (area code) - putNumber(contact, stripped, 3 + trunkPrefixOffset); - } - } - } - putNumber(contact, contact.phoneNumber, 0); - } - mSize++; - } - - public static CountryCodeWithOffset getOffsetWithoutCountryCode(String number) { - if (!TextUtils.isEmpty(number)) { - if (number.charAt(0) == '+') { - // check for international code here - for (int i = 1; i <= 1 + 3; i++) { - if (number.length() <= i) break; - final String countryCode = number.substring(1, i); - if (isValidCountryCode(countryCode)) { - return new CountryCodeWithOffset(i, countryCode); - } - } - } - } - return CountryCodeWithOffset.NO_COUNTRY_CODE; - } - - /** - * Used by SmartDialNameMatcher to determine which character in the phone number to start - * the matching process from for a NANP formatted number. - * - * @param number Raw phone number - * @return An empty array if the provided number does not appear to be an NANP number, - * and an array containing integer offsets for the number (starting after the '1' prefix, - * and the area code prefix respectively. - */ - public static int[] getOffsetForNANPNumbers(String number, SmartDialMap map) { - int validDigits = 0; - boolean hasPrefix = false; - int firstOffset = 0; // Tracks the location of the first digit after the '1' prefix - int secondOffset = 0; // Tracks the location of the first digit after the area code - for (int i = 0; i < number.length(); i++) { - final char ch = number.charAt(i); - if (map.isValidDialpadNumericChar(ch)) { - if (validDigits == 0) { - // Check the first digit to see if it is 1 - if (ch == '1') { - if (hasPrefix) { - // Prefix has two '1's in a row. Invalid number, since area codes - // cannot start with 1, so just bail - break; - } - hasPrefix = true; - continue; - } - } - validDigits++; - if (validDigits == 1) { - // Found the first digit after the country code - firstOffset = i; - } else if (validDigits == 4) { - // Found the first digit after the area code - secondOffset = i; - } - } - - } - if (validDigits == 10) { - return hasPrefix ? new int[] {firstOffset, secondOffset} : new int[] {secondOffset}; - } - return new int[0]; - } - - /** - * Converts the given characters into a byte array of index and returns it together with offset - * information in a {@link ParseInfo} data structure. - * @param chars Characters to convert into indexes - * @param firstNTokens The first n tokens we want the offset for - * @param lastNTokens The last n tokens we want the offset for - */ - @VisibleForTesting - ParseInfo parseToIndexes(CharSequence chars, int firstNTokens, int lastNTokens) { - final int length = chars.length(); - final byte[] result = new byte[length]; - final ArrayList<Integer> offSets = new ArrayList<Integer>(); - char c; - int tokenCount = 0; - boolean atSeparator = true; - for (int i = 0; i < length; i++) { - c = mMap.normalizeCharacter(chars.charAt(i)); - if (mMap.isValidDialpadCharacter(c)) { - if (atSeparator) { - tokenCount++; - } - atSeparator = false; - result[i] = mMap.getDialpadIndex(c); - } else { - // Found the last character of the current token - if (!atSeparator) { - offSets.add(i); - } - result[i] = -1; - atSeparator = true; - } - } - - final ParseInfo info = new ParseInfo(); - info.indexes = result; - info.nthFirstTokenPos = offSets.size() >= firstNTokens ? offSets.get(firstNTokens - 1) : - length - 1; - info.nthLastTokenPos = offSets.size() >= lastNTokens ? offSets.get(offSets.size() - - lastNTokens) : 0; - return info; - } - - /** - * Puts a phone number and its associated contact into the prefix trie. - * - * @param contact - Contact to add to the trie - * @param phoneNumber - Phone number of the contact - * @param offSet - The nth character of the phone number to start from - */ - private void putNumber(ContactNumber contact, String phoneNumber, int offSet) { - Preconditions.checkArgument(offSet < phoneNumber.length()); - Node current = mRoot; - final int length = phoneNumber.length(); - char ch; - for (int i = offSet; i < length; i++) { - ch = phoneNumber.charAt(i); - if (mMap.isValidDialpadNumericChar(ch)) { - current = current.getChild(ch, true); - } - } - current.add(contact); - } - - /** - * Place an contact into the trie using at the provided node using the provided prefix. Uses as - * the input prefix a byte array instead of a CharSequence, as we will be traversing the array - * multiple times and it is more efficient to pre-convert the prefix into indexes before hand. - * Adds initial matches for the first token, and the last N tokens in the name. - * - * @param contact Contact to put - * @param root Root node to use as the starting point - * @param parseInfo ParseInfo data structure containing the converted byte array, as well as - * token offsets that determine which tokens should have entries added for initial - * search - * @param start - Starting index of the byte array - * @param isFullName If true, prefix will be treated as a full name and everytime a new name - * token is encountered, the rest of the name segment is added into the trie. - */ - private void putForPrefix(ContactNumber contact, Node root, ParseInfo info, int start, - boolean isFullName) { - final boolean addInitialMatches = (start >= info.nthLastTokenPos || - start <= info.nthFirstTokenPos); - final byte[] indexes = info.indexes; - Node current = root; - Node initialNode = root; - final int length = indexes.length; - boolean atSeparator = true; - byte index; - for (int i = start; i < length; i++) { - index = indexes[i]; - if (index > -1) { - if (atSeparator) { - atSeparator = false; - // encountered a new name token, so add this token into the tree starting from - // the root node - if (initialNode != this.mRoot) { - if (isFullName) { - putForPrefix(contact, this.mRoot, info, i, false); - } - if (addInitialMatches && - (i >= info.nthLastTokenPos || i <= info.nthFirstTokenPos) && - initialNode != root) { - putForPrefix(contact, initialNode, info, i, false); - } - } - // Set initial node to the node indexed by the first character of the current - // prefix - if (initialNode == root) { - initialNode = initialNode.getChild(index, true); - } - } - current = current.getChild(index, true); - } else { - atSeparator = true; - } - } - current.add(contact); - } - - /* Used only for testing to verify we insert the correct number of entries into the trie */ - @VisibleForTesting - int numEntries() { - final ArrayList<ContactNumber> result = Lists.newArrayList(); - getAll(mRoot, result); - return result.size(); - } - - - @VisibleForTesting - public int size() { - return mSize; - } - - @VisibleForTesting - /* package */ static class Node { - Node[] mChildren; - private ArrayList<ContactNumber> mContents; - - public Node() { - // don't allocate array or contents unless needed - } - - public int getChildrenSize() { - if (mChildren == null) { - return -1; - } - return mChildren.length; - } - - /** - * Returns a specific child of the current node. - * - * @param index Index of the child to return. - * @param createIfDoesNotExist Whether or not to create a node in that child slot if one - * does not already currently exist. - * @return The existing or newly created child, or {@literal null} if the child does not - * exist and createIfDoesNotExist is false. - */ - public Node getChild(int index, boolean createIfDoesNotExist) { - if (createIfDoesNotExist) { - if (mChildren == null) { - mChildren = new Node[10]; - } - if (mChildren[index] == null) { - mChildren[index] = new Node(); - } - } else { - if (mChildren == null) { - return null; - } - } - return mChildren[index]; - } - - /** - * Same as getChild(int index, boolean createIfDoesNotExist), but takes a character from '0' - * to '9' as an index. - */ - public Node getChild(char index, boolean createIfDoesNotExist) { - return getChild(index - '0', createIfDoesNotExist); - } - - public void add(ContactNumber contact) { - if (mContents == null) { - mContents = Lists.newArrayList(); - } - mContents.add(contact); - } - - public ArrayList<ContactNumber> getContents() { - return mContents; - } - } - - private static boolean isValidCountryCode(String countryCode) { - if (sCountryCodes == null) { - sCountryCodes = initCountryCodes(); - } - return sCountryCodes.contains(countryCode); - } - - private static Set<String> initCountryCodes() { - final HashSet<String> result = new HashSet<String>(); - result.add("1"); - result.add("7"); - result.add("20"); - result.add("27"); - result.add("30"); - result.add("31"); - result.add("32"); - result.add("33"); - result.add("34"); - result.add("36"); - result.add("39"); - result.add("40"); - result.add("41"); - result.add("43"); - result.add("44"); - result.add("45"); - result.add("46"); - result.add("47"); - result.add("48"); - result.add("49"); - result.add("51"); - result.add("52"); - result.add("53"); - result.add("54"); - result.add("55"); - result.add("56"); - result.add("57"); - result.add("58"); - result.add("60"); - result.add("61"); - result.add("62"); - result.add("63"); - result.add("64"); - result.add("65"); - result.add("66"); - result.add("81"); - result.add("82"); - result.add("84"); - result.add("86"); - result.add("90"); - result.add("91"); - result.add("92"); - result.add("93"); - result.add("94"); - result.add("95"); - result.add("98"); - result.add("211"); - result.add("212"); - result.add("213"); - result.add("216"); - result.add("218"); - result.add("220"); - result.add("221"); - result.add("222"); - result.add("223"); - result.add("224"); - result.add("225"); - result.add("226"); - result.add("227"); - result.add("228"); - result.add("229"); - result.add("230"); - result.add("231"); - result.add("232"); - result.add("233"); - result.add("234"); - result.add("235"); - result.add("236"); - result.add("237"); - result.add("238"); - result.add("239"); - result.add("240"); - result.add("241"); - result.add("242"); - result.add("243"); - result.add("244"); - result.add("245"); - result.add("246"); - result.add("247"); - result.add("248"); - result.add("249"); - result.add("250"); - result.add("251"); - result.add("252"); - result.add("253"); - result.add("254"); - result.add("255"); - result.add("256"); - result.add("257"); - result.add("258"); - result.add("260"); - result.add("261"); - result.add("262"); - result.add("263"); - result.add("264"); - result.add("265"); - result.add("266"); - result.add("267"); - result.add("268"); - result.add("269"); - result.add("290"); - result.add("291"); - result.add("297"); - result.add("298"); - result.add("299"); - result.add("350"); - result.add("351"); - result.add("352"); - result.add("353"); - result.add("354"); - result.add("355"); - result.add("356"); - result.add("357"); - result.add("358"); - result.add("359"); - result.add("370"); - result.add("371"); - result.add("372"); - result.add("373"); - result.add("374"); - result.add("375"); - result.add("376"); - result.add("377"); - result.add("378"); - result.add("379"); - result.add("380"); - result.add("381"); - result.add("382"); - result.add("385"); - result.add("386"); - result.add("387"); - result.add("389"); - result.add("420"); - result.add("421"); - result.add("423"); - result.add("500"); - result.add("501"); - result.add("502"); - result.add("503"); - result.add("504"); - result.add("505"); - result.add("506"); - result.add("507"); - result.add("508"); - result.add("509"); - result.add("590"); - result.add("591"); - result.add("592"); - result.add("593"); - result.add("594"); - result.add("595"); - result.add("596"); - result.add("597"); - result.add("598"); - result.add("599"); - result.add("670"); - result.add("672"); - result.add("673"); - result.add("674"); - result.add("675"); - result.add("676"); - result.add("677"); - result.add("678"); - result.add("679"); - result.add("680"); - result.add("681"); - result.add("682"); - result.add("683"); - result.add("685"); - result.add("686"); - result.add("687"); - result.add("688"); - result.add("689"); - result.add("690"); - result.add("691"); - result.add("692"); - result.add("800"); - result.add("808"); - result.add("850"); - result.add("852"); - result.add("853"); - result.add("855"); - result.add("856"); - result.add("870"); - result.add("878"); - result.add("880"); - result.add("881"); - result.add("882"); - result.add("883"); - result.add("886"); - result.add("888"); - result.add("960"); - result.add("961"); - result.add("962"); - result.add("963"); - result.add("964"); - result.add("965"); - result.add("966"); - result.add("967"); - result.add("968"); - result.add("970"); - result.add("971"); - result.add("972"); - result.add("973"); - result.add("974"); - result.add("975"); - result.add("976"); - result.add("977"); - result.add("979"); - result.add("992"); - result.add("993"); - result.add("994"); - result.add("995"); - result.add("996"); - result.add("998"); - return result; - } -} |