Home | History | Annotate | Download | only in latin
      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