ContactsBinaryDictionary.java revision 18df1925358229e98ad1db3d409577353a8e9a92
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.ExecutorUtils;
35import com.android.inputmethod.latin.utils.StringUtils;
36
37import java.io.File;
38import java.util.ArrayList;
39import java.util.List;
40import java.util.Locale;
41
42public 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 + ", "
237                                + prevWordsInfo.mPrevWord);
238                    }
239                    runGCIfRequiredLocked(true /* mindsBlockByGC */);
240                    addUnigramLocked(word, FREQUENCY_FOR_CONTACTS,
241                            null /* shortcut */, 0 /* shortcutFreq */, false /* isNotAWord */,
242                            false /* isBlacklisted */, BinaryDictionary.NOT_A_VALID_TIMESTAMP);
243                    if (!TextUtils.isEmpty(prevWordsInfo.mPrevWord) && mUseFirstLastBigrams) {
244                        runGCIfRequiredLocked(true /* mindsBlockByGC */);
245                        addNgramEntryLocked(prevWordsInfo, word, FREQUENCY_FOR_CONTACTS_BIGRAM,
246                                BinaryDictionary.NOT_A_VALID_TIMESTAMP);
247                    }
248                    prevWordsInfo = new PrevWordsInfo(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