ContactsBinaryDictionary.java revision 1737b7ff5c59b68693b85a410d5b5a901407087e
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.latin.personalization.AccountUtils; 33import com.android.inputmethod.latin.utils.StringUtils; 34 35import java.io.File; 36import java.util.List; 37import java.util.Locale; 38 39public class ContactsBinaryDictionary extends ExpandableBinaryDictionary { 40 41 private static final String[] PROJECTION = {BaseColumns._ID, Contacts.DISPLAY_NAME}; 42 private static final String[] PROJECTION_ID_ONLY = {BaseColumns._ID}; 43 44 private static final String TAG = ContactsBinaryDictionary.class.getSimpleName(); 45 private static final String NAME = "contacts"; 46 47 private static final boolean DEBUG = false; 48 private static final boolean DEBUG_DUMP = false; 49 50 /** 51 * Frequency for contacts information into the dictionary 52 */ 53 private static final int FREQUENCY_FOR_CONTACTS = 40; 54 private static final int FREQUENCY_FOR_CONTACTS_BIGRAM = 90; 55 56 /** The maximum number of contacts that this dictionary supports. */ 57 private static final int MAX_CONTACT_COUNT = 10000; 58 59 private static final int INDEX_NAME = 1; 60 61 /** The number of contacts in the most recent dictionary rebuild. */ 62 static private int sContactCountAtLastRebuild = 0; 63 64 /** The locale for this contacts dictionary. Controls name bigram predictions. */ 65 public final Locale mLocale; 66 67 private ContentObserver mObserver; 68 69 /** 70 * Whether to use "firstname lastname" in bigram predictions. 71 */ 72 private final boolean mUseFirstLastBigrams; 73 74 public ContactsBinaryDictionary(final Context context, final Locale locale) { 75 this(context, locale, null /* dictFile */); 76 } 77 78 public 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 mLocale = locale; 88 mUseFirstLastBigrams = useFirstLastBigramsForLocale(locale); 89 registerObserver(context); 90 reloadDictionaryIfRequired(); 91 } 92 93 private synchronized void registerObserver(final Context context) { 94 if (mObserver != null) return; 95 ContentResolver cres = context.getContentResolver(); 96 cres.registerContentObserver(Contacts.CONTENT_URI, true, mObserver = 97 new ContentObserver(null) { 98 @Override 99 public void onChange(boolean self) { 100 setNeedsToReload(); 101 } 102 }); 103 } 104 105 @Override 106 public synchronized void close() { 107 if (mObserver != null) { 108 mContext.getContentResolver().unregisterContentObserver(mObserver); 109 mObserver = null; 110 } 111 super.close(); 112 } 113 114 @Override 115 public void loadInitialContentsLocked() { 116 loadDeviceAccountsEmailAddressesLocked(); 117 loadDictionaryForUriLocked(ContactsContract.Profile.CONTENT_URI); 118 // TODO: Switch this URL to the newer ContactsContract too 119 loadDictionaryForUriLocked(Contacts.CONTENT_URI); 120 } 121 122 private void loadDeviceAccountsEmailAddressesLocked() { 123 final List<String> accountVocabulary = 124 AccountUtils.getDeviceAccountsEmailAddresses(mContext); 125 if (accountVocabulary == null || accountVocabulary.isEmpty()) { 126 return; 127 } 128 for (String word : accountVocabulary) { 129 if (DEBUG) { 130 Log.d(TAG, "loadAccountVocabulary: " + word); 131 } 132 runGCIfRequiredLocked(true /* mindsBlockByGC */); 133 addWordDynamicallyLocked(word, FREQUENCY_FOR_CONTACTS, null /* shortcut */, 134 0 /* shortcutFreq */, false /* isNotAWord */, false /* isBlacklisted */, 135 BinaryDictionary.NOT_A_VALID_TIMESTAMP); 136 } 137 } 138 139 private void loadDictionaryForUriLocked(final Uri uri) { 140 Cursor cursor = null; 141 try { 142 cursor = mContext.getContentResolver().query(uri, PROJECTION, null, null, null); 143 if (null == cursor) { 144 return; 145 } 146 if (cursor.moveToFirst()) { 147 sContactCountAtLastRebuild = getContactCount(); 148 addWordsLocked(cursor); 149 } 150 } catch (final SQLiteException e) { 151 Log.e(TAG, "SQLiteException in the remote Contacts process.", e); 152 } catch (final IllegalStateException e) { 153 Log.e(TAG, "Contacts DB is having problems", e); 154 } finally { 155 if (null != cursor) { 156 cursor.close(); 157 } 158 } 159 } 160 161 private boolean useFirstLastBigramsForLocale(final Locale locale) { 162 // TODO: Add firstname/lastname bigram rules for other languages. 163 if (locale != null && locale.getLanguage().equals(Locale.ENGLISH.getLanguage())) { 164 return true; 165 } 166 return false; 167 } 168 169 private void addWordsLocked(final Cursor cursor) { 170 int count = 0; 171 while (!cursor.isAfterLast() && count < MAX_CONTACT_COUNT) { 172 String name = cursor.getString(INDEX_NAME); 173 if (isValidName(name)) { 174 addNameLocked(name); 175 ++count; 176 } else { 177 if (DEBUG_DUMP) { 178 Log.d(TAG, "Invalid name: " + name); 179 } 180 } 181 cursor.moveToNext(); 182 } 183 } 184 185 private int getContactCount() { 186 // TODO: consider switching to a rawQuery("select count(*)...") on the database if 187 // performance is a bottleneck. 188 Cursor cursor = null; 189 try { 190 cursor = mContext.getContentResolver().query(Contacts.CONTENT_URI, PROJECTION_ID_ONLY, 191 null, null, null); 192 if (null == cursor) { 193 return 0; 194 } 195 return cursor.getCount(); 196 } catch (final SQLiteException e) { 197 Log.e(TAG, "SQLiteException in the remote Contacts process.", e); 198 } finally { 199 if (null != cursor) { 200 cursor.close(); 201 } 202 } 203 return 0; 204 } 205 206 /** 207 * Adds the words in a name (e.g., firstname/lastname) to the binary dictionary along with their 208 * bigrams depending on locale. 209 */ 210 private void addNameLocked(final String name) { 211 int len = StringUtils.codePointCount(name); 212 String prevWord = null; 213 // TODO: Better tokenization for non-Latin writing systems 214 for (int i = 0; i < len; i++) { 215 if (Character.isLetter(name.codePointAt(i))) { 216 int end = getWordEndPosition(name, len, i); 217 String word = name.substring(i, end); 218 if (DEBUG_DUMP) { 219 Log.d(TAG, "addName word = " + word); 220 } 221 i = end - 1; 222 // Don't add single letter words, possibly confuses 223 // capitalization of i. 224 final int wordLen = StringUtils.codePointCount(word); 225 if (wordLen < MAX_WORD_LENGTH && wordLen > 1) { 226 if (DEBUG) { 227 Log.d(TAG, "addName " + name + ", " + word + ", " + prevWord); 228 } 229 runGCIfRequiredLocked(true /* mindsBlockByGC */); 230 addWordDynamicallyLocked(word, FREQUENCY_FOR_CONTACTS, 231 null /* shortcut */, 0 /* shortcutFreq */, false /* isNotAWord */, 232 false /* isBlacklisted */, BinaryDictionary.NOT_A_VALID_TIMESTAMP); 233 if (!TextUtils.isEmpty(prevWord) && mUseFirstLastBigrams) { 234 runGCIfRequiredLocked(true /* mindsBlockByGC */); 235 addBigramDynamicallyLocked(prevWord, word, 236 FREQUENCY_FOR_CONTACTS_BIGRAM, 237 BinaryDictionary.NOT_A_VALID_TIMESTAMP); 238 } 239 prevWord = word; 240 } 241 } 242 } 243 } 244 245 /** 246 * Returns the index of the last letter in the word, starting from position startIndex. 247 */ 248 private static int getWordEndPosition(final String string, final int len, 249 final int startIndex) { 250 int end; 251 int cp = 0; 252 for (end = startIndex + 1; end < len; end += Character.charCount(cp)) { 253 cp = string.codePointAt(end); 254 if (!(cp == Constants.CODE_DASH || cp == Constants.CODE_SINGLE_QUOTE 255 || Character.isLetter(cp))) { 256 break; 257 } 258 } 259 return end; 260 } 261 262 @Override 263 protected boolean haveContentsChanged() { 264 final long startTime = SystemClock.uptimeMillis(); 265 final int contactCount = getContactCount(); 266 if (contactCount > MAX_CONTACT_COUNT) { 267 // If there are too many contacts then return false. In this rare case it is impossible 268 // to include all of them anyways and the cost of rebuilding the dictionary is too high. 269 // TODO: Sort and check only the MAX_CONTACT_COUNT most recent contacts? 270 return false; 271 } 272 if (contactCount != sContactCountAtLastRebuild) { 273 if (DEBUG) { 274 Log.d(TAG, "Contact count changed: " + sContactCountAtLastRebuild + " to " 275 + contactCount); 276 } 277 return true; 278 } 279 // Check all contacts since it's not possible to find out which names have changed. 280 // This is needed because it's possible to receive extraneous onChange events even when no 281 // name has changed. 282 final Cursor cursor = mContext.getContentResolver().query(Contacts.CONTENT_URI, PROJECTION, 283 null, null, null); 284 if (null == cursor) { 285 return false; 286 } 287 try { 288 if (cursor.moveToFirst()) { 289 while (!cursor.isAfterLast()) { 290 String name = cursor.getString(INDEX_NAME); 291 if (isValidName(name) && !isNameInDictionaryLocked(name)) { 292 if (DEBUG) { 293 Log.d(TAG, "Contact name missing: " + name + " (runtime = " 294 + (SystemClock.uptimeMillis() - startTime) + " ms)"); 295 } 296 return true; 297 } 298 cursor.moveToNext(); 299 } 300 } 301 } finally { 302 cursor.close(); 303 } 304 if (DEBUG) { 305 Log.d(TAG, "No contacts changed. (runtime = " + (SystemClock.uptimeMillis() - startTime) 306 + " ms)"); 307 } 308 return false; 309 } 310 311 private static boolean isValidName(final String name) { 312 if (name != null && -1 == name.indexOf(Constants.CODE_COMMERCIAL_AT)) { 313 return true; 314 } 315 return false; 316 } 317 318 /** 319 * Checks if the words in a name are in the current binary dictionary. 320 */ 321 private boolean isNameInDictionaryLocked(final String name) { 322 int len = StringUtils.codePointCount(name); 323 String prevWord = null; 324 for (int i = 0; i < len; i++) { 325 if (Character.isLetter(name.codePointAt(i))) { 326 int end = getWordEndPosition(name, len, i); 327 String word = name.substring(i, end); 328 i = end - 1; 329 final int wordLen = StringUtils.codePointCount(word); 330 if (wordLen < MAX_WORD_LENGTH && wordLen > 1) { 331 if (!TextUtils.isEmpty(prevWord) && mUseFirstLastBigrams) { 332 if (!isValidBigramLocked(prevWord, word)) { 333 return false; 334 } 335 } else { 336 if (!isValidWordLocked(word)) { 337 return false; 338 } 339 } 340 prevWord = word; 341 } 342 } 343 } 344 return true; 345 } 346} 347