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