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