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