ContactsBinaryDictionary.java revision a790c5b68324da41428aeb68594d43ca5632f66d
1/* 2 * Copyright (C) 2012 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.inputmethod.latin; 18 19import android.content.ContentResolver; 20import android.content.Context; 21import android.database.ContentObserver; 22import android.database.Cursor; 23import android.database.sqlite.SQLiteException; 24import android.net.Uri; 25import android.os.SystemClock; 26import android.provider.BaseColumns; 27import android.provider.ContactsContract; 28import android.provider.ContactsContract.Contacts; 29import android.text.TextUtils; 30import android.util.Log; 31 32import com.android.inputmethod.annotations.UsedForTesting; 33import com.android.inputmethod.latin.personalization.AccountUtils; 34import com.android.inputmethod.latin.utils.CollectionUtils; 35import com.android.inputmethod.latin.utils.ExecutorUtils; 36import com.android.inputmethod.latin.utils.StringUtils; 37 38import java.io.File; 39import java.util.ArrayList; 40import java.util.List; 41import java.util.Locale; 42 43public class ContactsBinaryDictionary extends ExpandableBinaryDictionary { 44 45 private static final String[] PROJECTION = {BaseColumns._ID, Contacts.DISPLAY_NAME}; 46 private static final String[] PROJECTION_ID_ONLY = {BaseColumns._ID}; 47 48 private static final String TAG = ContactsBinaryDictionary.class.getSimpleName(); 49 private static final String NAME = "contacts"; 50 51 private static final boolean DEBUG = false; 52 private static final boolean DEBUG_DUMP = false; 53 54 /** 55 * Frequency for contacts information into the dictionary 56 */ 57 private static final int FREQUENCY_FOR_CONTACTS = 40; 58 private static final int FREQUENCY_FOR_CONTACTS_BIGRAM = 90; 59 60 /** The maximum number of contacts that this dictionary supports. */ 61 private static final int MAX_CONTACT_COUNT = 10000; 62 63 private static final int INDEX_NAME = 1; 64 65 /** The number of contacts in the most recent dictionary rebuild. */ 66 private int mContactCountAtLastRebuild = 0; 67 68 /** The hash code of ArrayList of contacts names in the most recent dictionary rebuild. */ 69 private int mHashCodeAtLastRebuild = 0; 70 71 private ContentObserver mObserver; 72 73 /** 74 * Whether to use "firstname lastname" in bigram predictions. 75 */ 76 private final boolean mUseFirstLastBigrams; 77 78 private ContactsBinaryDictionary(final Context context, final Locale locale, 79 final File dictFile) { 80 this(context, locale, dictFile, NAME); 81 } 82 83 protected ContactsBinaryDictionary(final Context context, final Locale locale, 84 final File dictFile, final String name) { 85 super(context, getDictName(name, locale, dictFile), locale, Dictionary.TYPE_CONTACTS, 86 dictFile); 87 mUseFirstLastBigrams = useFirstLastBigramsForLocale(locale); 88 registerObserver(context); 89 reloadDictionaryIfRequired(); 90 } 91 92 @UsedForTesting 93 public static ContactsBinaryDictionary getDictionary(final Context context, final Locale locale, 94 final File dictFile) { 95 return new ContactsBinaryDictionary(context, locale, dictFile); 96 } 97 98 private synchronized void registerObserver(final Context context) { 99 if (mObserver != null) return; 100 ContentResolver cres = context.getContentResolver(); 101 cres.registerContentObserver(Contacts.CONTENT_URI, true, mObserver = 102 new ContentObserver(null) { 103 @Override 104 public void onChange(boolean self) { 105 ExecutorUtils.getExecutor("Check Contacts").execute(new Runnable() { 106 @Override 107 public void run() { 108 if (haveContentsChanged()) { 109 setNeedsToRecreate(); 110 } 111 } 112 }); 113 } 114 }); 115 } 116 117 @Override 118 public synchronized void close() { 119 if (mObserver != null) { 120 mContext.getContentResolver().unregisterContentObserver(mObserver); 121 mObserver = null; 122 } 123 super.close(); 124 } 125 126 @Override 127 public void loadInitialContentsLocked() { 128 loadDeviceAccountsEmailAddressesLocked(); 129 loadDictionaryForUriLocked(ContactsContract.Profile.CONTENT_URI); 130 // TODO: Switch this URL to the newer ContactsContract too 131 loadDictionaryForUriLocked(Contacts.CONTENT_URI); 132 } 133 134 private void loadDeviceAccountsEmailAddressesLocked() { 135 final List<String> accountVocabulary = 136 AccountUtils.getDeviceAccountsEmailAddresses(mContext); 137 if (accountVocabulary == null || accountVocabulary.isEmpty()) { 138 return; 139 } 140 for (String word : accountVocabulary) { 141 if (DEBUG) { 142 Log.d(TAG, "loadAccountVocabulary: " + word); 143 } 144 runGCIfRequiredLocked(true /* mindsBlockByGC */); 145 addUnigramLocked(word, FREQUENCY_FOR_CONTACTS, null /* shortcut */, 146 0 /* shortcutFreq */, false /* isNotAWord */, false /* isBlacklisted */, 147 BinaryDictionary.NOT_A_VALID_TIMESTAMP); 148 } 149 } 150 151 private void loadDictionaryForUriLocked(final Uri uri) { 152 Cursor cursor = null; 153 try { 154 cursor = mContext.getContentResolver().query(uri, PROJECTION, null, null, null); 155 if (null == cursor) { 156 return; 157 } 158 if (cursor.moveToFirst()) { 159 mContactCountAtLastRebuild = getContactCount(); 160 addWordsLocked(cursor); 161 } 162 } catch (final SQLiteException e) { 163 Log.e(TAG, "SQLiteException in the remote Contacts process.", e); 164 } catch (final IllegalStateException e) { 165 Log.e(TAG, "Contacts DB is having problems", e); 166 } finally { 167 if (null != cursor) { 168 cursor.close(); 169 } 170 } 171 } 172 173 private boolean useFirstLastBigramsForLocale(final Locale locale) { 174 // TODO: Add firstname/lastname bigram rules for other languages. 175 if (locale != null && locale.getLanguage().equals(Locale.ENGLISH.getLanguage())) { 176 return true; 177 } 178 return false; 179 } 180 181 private void addWordsLocked(final Cursor cursor) { 182 int count = 0; 183 final ArrayList<String> names = CollectionUtils.newArrayList(); 184 while (!cursor.isAfterLast() && count < MAX_CONTACT_COUNT) { 185 String name = cursor.getString(INDEX_NAME); 186 if (isValidName(name)) { 187 names.add(name); 188 addNameLocked(name); 189 ++count; 190 } else { 191 if (DEBUG_DUMP) { 192 Log.d(TAG, "Invalid name: " + name); 193 } 194 } 195 cursor.moveToNext(); 196 } 197 mHashCodeAtLastRebuild = names.hashCode(); 198 } 199 200 private int getContactCount() { 201 // TODO: consider switching to a rawQuery("select count(*)...") on the database if 202 // performance is a bottleneck. 203 Cursor cursor = null; 204 try { 205 cursor = mContext.getContentResolver().query(Contacts.CONTENT_URI, PROJECTION_ID_ONLY, 206 null, null, null); 207 if (null == cursor) { 208 return 0; 209 } 210 return cursor.getCount(); 211 } catch (final SQLiteException e) { 212 Log.e(TAG, "SQLiteException in the remote Contacts process.", e); 213 } finally { 214 if (null != cursor) { 215 cursor.close(); 216 } 217 } 218 return 0; 219 } 220 221 /** 222 * Adds the words in a name (e.g., firstname/lastname) to the binary dictionary along with their 223 * bigrams depending on locale. 224 */ 225 private void addNameLocked(final String name) { 226 int len = StringUtils.codePointCount(name); 227 PrevWordsInfo prevWordsInfo = PrevWordsInfo.EMPTY_PREV_WORDS_INFO; 228 // TODO: Better tokenization for non-Latin writing systems 229 for (int i = 0; i < len; i++) { 230 if (Character.isLetter(name.codePointAt(i))) { 231 int end = getWordEndPosition(name, len, i); 232 String word = name.substring(i, end); 233 if (DEBUG_DUMP) { 234 Log.d(TAG, "addName word = " + word); 235 } 236 i = end - 1; 237 // Don't add single letter words, possibly confuses 238 // capitalization of i. 239 final int wordLen = StringUtils.codePointCount(word); 240 if (wordLen < MAX_WORD_LENGTH && wordLen > 1) { 241 if (DEBUG) { 242 Log.d(TAG, "addName " + name + ", " + word + ", " 243 + prevWordsInfo.mPrevWord); 244 } 245 runGCIfRequiredLocked(true /* mindsBlockByGC */); 246 addUnigramLocked(word, FREQUENCY_FOR_CONTACTS, 247 null /* shortcut */, 0 /* shortcutFreq */, false /* isNotAWord */, 248 false /* isBlacklisted */, BinaryDictionary.NOT_A_VALID_TIMESTAMP); 249 if (!TextUtils.isEmpty(prevWordsInfo.mPrevWord) && mUseFirstLastBigrams) { 250 runGCIfRequiredLocked(true /* mindsBlockByGC */); 251 addNgramEntryLocked(prevWordsInfo, word, FREQUENCY_FOR_CONTACTS_BIGRAM, 252 BinaryDictionary.NOT_A_VALID_TIMESTAMP); 253 } 254 prevWordsInfo = new PrevWordsInfo(word); 255 } 256 } 257 } 258 } 259 260 /** 261 * Returns the index of the last letter in the word, starting from position startIndex. 262 */ 263 private static int getWordEndPosition(final String string, final int len, 264 final int startIndex) { 265 int end; 266 int cp = 0; 267 for (end = startIndex + 1; end < len; end += Character.charCount(cp)) { 268 cp = string.codePointAt(end); 269 if (!(cp == Constants.CODE_DASH || cp == Constants.CODE_SINGLE_QUOTE 270 || Character.isLetter(cp))) { 271 break; 272 } 273 } 274 return end; 275 } 276 277 private boolean haveContentsChanged() { 278 final long startTime = SystemClock.uptimeMillis(); 279 final int contactCount = getContactCount(); 280 if (contactCount > MAX_CONTACT_COUNT) { 281 // If there are too many contacts then return false. In this rare case it is impossible 282 // to include all of them anyways and the cost of rebuilding the dictionary is too high. 283 // TODO: Sort and check only the MAX_CONTACT_COUNT most recent contacts? 284 return false; 285 } 286 if (contactCount != mContactCountAtLastRebuild) { 287 if (DEBUG) { 288 Log.d(TAG, "Contact count changed: " + mContactCountAtLastRebuild + " to " 289 + contactCount); 290 } 291 return true; 292 } 293 // Check all contacts since it's not possible to find out which names have changed. 294 // This is needed because it's possible to receive extraneous onChange events even when no 295 // name has changed. 296 final Cursor cursor = mContext.getContentResolver().query(Contacts.CONTENT_URI, PROJECTION, 297 null, null, null); 298 if (null == cursor) { 299 return false; 300 } 301 final ArrayList<String> names = CollectionUtils.newArrayList(); 302 try { 303 if (cursor.moveToFirst()) { 304 while (!cursor.isAfterLast()) { 305 String name = cursor.getString(INDEX_NAME); 306 if (isValidName(name)) { 307 names.add(name); 308 } 309 cursor.moveToNext(); 310 } 311 } 312 if (names.hashCode() != mHashCodeAtLastRebuild) { 313 return true; 314 } 315 } finally { 316 cursor.close(); 317 } 318 if (DEBUG) { 319 Log.d(TAG, "No contacts changed. (runtime = " + (SystemClock.uptimeMillis() - startTime) 320 + " ms)"); 321 } 322 return false; 323 } 324 325 private static boolean isValidName(final String name) { 326 if (name != null && -1 == name.indexOf(Constants.CODE_COMMERCIAL_AT)) { 327 return true; 328 } 329 return false; 330 } 331} 332