1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 * in compliance with the License. You may obtain a copy of the License at
6 *
7 * http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software distributed under the License
10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11 * or implied. See the License for the specific language governing permissions and limitations under
12 * the License.
13 */
14
15package com.android.inputmethod.latin;
16
17import android.content.ContentResolver;
18import android.content.Context;
19import android.database.ContentObserver;
20import android.database.Cursor;
21import android.os.SystemClock;
22import android.provider.BaseColumns;
23import android.provider.ContactsContract.Contacts;
24import android.text.TextUtils;
25import android.util.Log;
26
27import com.android.inputmethod.keyboard.Keyboard;
28
29import java.util.Locale;
30
31public class ContactsBinaryDictionary extends ExpandableBinaryDictionary {
32
33    private static final String[] PROJECTION = {BaseColumns._ID, Contacts.DISPLAY_NAME,};
34    private static final String[] PROJECTION_ID_ONLY = {BaseColumns._ID};
35
36    private static final String TAG = ContactsBinaryDictionary.class.getSimpleName();
37    private static final String NAME = "contacts";
38
39    private static boolean DEBUG = false;
40
41    /**
42     * Frequency for contacts information into the dictionary
43     */
44    private static final int FREQUENCY_FOR_CONTACTS = 40;
45    private static final int FREQUENCY_FOR_CONTACTS_BIGRAM = 90;
46
47    /** The maximum number of contacts that this dictionary supports. */
48    private static final int MAX_CONTACT_COUNT = 10000;
49
50    private static final int INDEX_NAME = 1;
51
52    /** The number of contacts in the most recent dictionary rebuild. */
53    static private int sContactCountAtLastRebuild = 0;
54
55    /** The locale for this contacts dictionary. Controls name bigram predictions. */
56    public final Locale mLocale;
57
58    private ContentObserver mObserver;
59
60    /**
61     * Whether to use "firstname lastname" in bigram predictions.
62     */
63    private final boolean mUseFirstLastBigrams;
64
65    public ContactsBinaryDictionary(final Context context, Locale locale) {
66        super(context, getFilenameWithLocale(NAME, locale.toString()), Dictionary.TYPE_CONTACTS);
67        mLocale = locale;
68        mUseFirstLastBigrams = useFirstLastBigramsForLocale(locale);
69        registerObserver(context);
70
71        // Load the current binary dictionary from internal storage. If no binary dictionary exists,
72        // loadDictionary will start a new thread to generate one asynchronously.
73        loadDictionary();
74    }
75
76    private synchronized void registerObserver(final Context context) {
77        // Perform a managed query. The Activity will handle closing and requerying the cursor
78        // when needed.
79        if (mObserver != null) return;
80        ContentResolver cres = context.getContentResolver();
81        cres.registerContentObserver(Contacts.CONTENT_URI, true, mObserver =
82                new ContentObserver(null) {
83                    @Override
84                    public void onChange(boolean self) {
85                        setRequiresReload(true);
86                    }
87                });
88    }
89
90    public void reopen(final Context context) {
91        registerObserver(context);
92    }
93
94    @Override
95    public synchronized void close() {
96        if (mObserver != null) {
97            mContext.getContentResolver().unregisterContentObserver(mObserver);
98            mObserver = null;
99        }
100        super.close();
101    }
102
103    @Override
104    public void loadDictionaryAsync() {
105        try {
106            Cursor cursor = mContext.getContentResolver()
107                    .query(Contacts.CONTENT_URI, PROJECTION, null, null, null);
108            if (cursor != null) {
109                try {
110                    if (cursor.moveToFirst()) {
111                        sContactCountAtLastRebuild = getContactCount();
112                        addWords(cursor);
113                    }
114                } finally {
115                    cursor.close();
116                }
117            }
118        } catch (IllegalStateException e) {
119            Log.e(TAG, "Contacts DB is having problems");
120        }
121    }
122
123    private boolean useFirstLastBigramsForLocale(Locale locale) {
124        // TODO: Add firstname/lastname bigram rules for other languages.
125        if (locale != null && locale.getLanguage().equals(Locale.ENGLISH.getLanguage())) {
126            return true;
127        }
128        return false;
129    }
130
131    private void addWords(Cursor cursor) {
132        clearFusionDictionary();
133        int count = 0;
134        while (!cursor.isAfterLast() && count < MAX_CONTACT_COUNT) {
135            String name = cursor.getString(INDEX_NAME);
136            if (isValidName(name)) {
137                addName(name);
138                ++count;
139            }
140            cursor.moveToNext();
141        }
142    }
143
144    private int getContactCount() {
145        // TODO: consider switching to a rawQuery("select count(*)...") on the database if
146        // performance is a bottleneck.
147        final Cursor cursor = mContext.getContentResolver().query(
148                Contacts.CONTENT_URI, PROJECTION_ID_ONLY, null, null, null);
149        if (cursor != null) {
150            try {
151                return cursor.getCount();
152            } finally {
153                cursor.close();
154            }
155        }
156        return 0;
157    }
158
159    /**
160     * Adds the words in a name (e.g., firstname/lastname) to the binary dictionary along with their
161     * bigrams depending on locale.
162     */
163    private void addName(String name) {
164        int len = StringUtils.codePointCount(name);
165        String prevWord = null;
166        // TODO: Better tokenization for non-Latin writing systems
167        for (int i = 0; i < len; i++) {
168            if (Character.isLetter(name.codePointAt(i))) {
169                int end = getWordEndPosition(name, len, i);
170                String word = name.substring(i, end);
171                i = end - 1;
172                // Don't add single letter words, possibly confuses
173                // capitalization of i.
174                final int wordLen = StringUtils.codePointCount(word);
175                if (wordLen < MAX_WORD_LENGTH && wordLen > 1) {
176                    super.addWord(word, null /* shortcut */, FREQUENCY_FOR_CONTACTS);
177                    if (!TextUtils.isEmpty(prevWord)) {
178                        if (mUseFirstLastBigrams) {
179                            super.setBigram(prevWord, word, FREQUENCY_FOR_CONTACTS_BIGRAM);
180                        }
181                    }
182                    prevWord = word;
183                }
184            }
185        }
186    }
187
188    /**
189     * Returns the index of the last letter in the word, starting from position startIndex.
190     */
191    private static int getWordEndPosition(String string, int len, int startIndex) {
192        int end;
193        int cp = 0;
194        for (end = startIndex + 1; end < len; end += Character.charCount(cp)) {
195            cp = string.codePointAt(end);
196            if (!(cp == Keyboard.CODE_DASH || cp == Keyboard.CODE_SINGLE_QUOTE
197                    || Character.isLetter(cp))) {
198                break;
199            }
200        }
201        return end;
202    }
203
204    @Override
205    protected boolean hasContentChanged() {
206        final long startTime = SystemClock.uptimeMillis();
207        final int contactCount = getContactCount();
208        if (contactCount > MAX_CONTACT_COUNT) {
209            // If there are too many contacts then return false. In this rare case it is impossible
210            // to include all of them anyways and the cost of rebuilding the dictionary is too high.
211            // TODO: Sort and check only the MAX_CONTACT_COUNT most recent contacts?
212            return false;
213        }
214        if (contactCount != sContactCountAtLastRebuild) {
215            if (DEBUG) {
216                Log.d(TAG, "Contact count changed: " + sContactCountAtLastRebuild + " to "
217                        + contactCount);
218            }
219            return true;
220        }
221        // Check all contacts since it's not possible to find out which names have changed.
222        // This is needed because it's possible to receive extraneous onChange events even when no
223        // name has changed.
224        Cursor cursor = mContext.getContentResolver().query(
225                Contacts.CONTENT_URI, PROJECTION, null, null, null);
226        if (cursor != null) {
227            try {
228                if (cursor.moveToFirst()) {
229                    while (!cursor.isAfterLast()) {
230                        String name = cursor.getString(INDEX_NAME);
231                        if (isValidName(name) && !isNameInDictionary(name)) {
232                            if (DEBUG) {
233                                Log.d(TAG, "Contact name missing: " + name + " (runtime = "
234                                        + (SystemClock.uptimeMillis() - startTime) + " ms)");
235                            }
236                            return true;
237                        }
238                        cursor.moveToNext();
239                    }
240                }
241            } finally {
242                cursor.close();
243            }
244        }
245        if (DEBUG) {
246            Log.d(TAG, "No contacts changed. (runtime = " + (SystemClock.uptimeMillis() - startTime)
247                    + " ms)");
248        }
249        return false;
250    }
251
252    private static boolean isValidName(String name) {
253        if (name != null && -1 == name.indexOf('@')) {
254            return true;
255        }
256        return false;
257    }
258
259    /**
260     * Checks if the words in a name are in the current binary dictionary.
261     */
262    private boolean isNameInDictionary(String name) {
263        int len = StringUtils.codePointCount(name);
264        String prevWord = null;
265        for (int i = 0; i < len; i++) {
266            if (Character.isLetter(name.codePointAt(i))) {
267                int end = getWordEndPosition(name, len, i);
268                String word = name.substring(i, end);
269                i = end - 1;
270                final int wordLen = StringUtils.codePointCount(word);
271                if (wordLen < MAX_WORD_LENGTH && wordLen > 1) {
272                    if (!TextUtils.isEmpty(prevWord) && mUseFirstLastBigrams) {
273                        if (!super.isValidBigramLocked(prevWord, word)) {
274                            return false;
275                        }
276                    } else {
277                        if (!super.isValidWordLocked(word)) {
278                            return false;
279                        }
280                    }
281                    prevWord = word;
282                }
283            }
284        }
285        return true;
286    }
287}
288