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