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.ContentProviderClient;
20import android.content.ContentResolver;
21import android.content.Context;
22import android.database.ContentObserver;
23import android.database.Cursor;
24import android.database.sqlite.SQLiteException;
25import android.net.Uri;
26import android.os.Build;
27import android.provider.UserDictionary.Words;
28import android.text.TextUtils;
29import android.util.Log;
30
31import com.android.inputmethod.annotations.UsedForTesting;
32import com.android.inputmethod.compat.UserDictionaryCompatUtils;
33import com.android.inputmethod.latin.utils.SubtypeLocaleUtils;
34
35import java.io.File;
36import java.util.Arrays;
37import java.util.Locale;
38
39/**
40 * An expandable dictionary that stores the words in the user dictionary provider into a binary
41 * dictionary file to use it from native code.
42 */
43public class UserBinaryDictionary extends ExpandableBinaryDictionary {
44    private static final String TAG = ExpandableBinaryDictionary.class.getSimpleName();
45
46    // The user dictionary provider uses an empty string to mean "all languages".
47    private static final String USER_DICTIONARY_ALL_LANGUAGES = "";
48    private static final int HISTORICAL_DEFAULT_USER_DICTIONARY_FREQUENCY = 250;
49    private static final int LATINIME_DEFAULT_USER_DICTIONARY_FREQUENCY = 160;
50    // Shortcut frequency is 0~15, with 15 = whitelist. We don't want user dictionary entries
51    // to auto-correct, so we set this to the highest frequency that won't, i.e. 14.
52    private static final int USER_DICT_SHORTCUT_FREQUENCY = 14;
53
54    private static final String[] PROJECTION_QUERY_WITH_SHORTCUT = new String[] {
55        Words.WORD,
56        Words.SHORTCUT,
57        Words.FREQUENCY,
58    };
59    private static final String[] PROJECTION_QUERY_WITHOUT_SHORTCUT = new String[] {
60        Words.WORD,
61        Words.FREQUENCY,
62    };
63
64    private static final String NAME = "userunigram";
65
66    private ContentObserver mObserver;
67    final private String mLocale;
68    final private boolean mAlsoUseMoreRestrictiveLocales;
69
70    protected UserBinaryDictionary(final Context context, final Locale locale,
71            final boolean alsoUseMoreRestrictiveLocales, final File dictFile, final String name) {
72        super(context, getDictName(name, locale, dictFile), locale, Dictionary.TYPE_USER, dictFile);
73        if (null == locale) throw new NullPointerException(); // Catch the error earlier
74        final String localeStr = locale.toString();
75        if (SubtypeLocaleUtils.NO_LANGUAGE.equals(localeStr)) {
76            // If we don't have a locale, insert into the "all locales" user dictionary.
77            mLocale = USER_DICTIONARY_ALL_LANGUAGES;
78        } else {
79            mLocale = localeStr;
80        }
81        mAlsoUseMoreRestrictiveLocales = alsoUseMoreRestrictiveLocales;
82        ContentResolver cres = context.getContentResolver();
83
84        mObserver = new ContentObserver(null) {
85            @Override
86            public void onChange(final boolean self) {
87                // This hook is deprecated as of API level 16 (Build.VERSION_CODES.JELLY_BEAN),
88                // but should still be supported for cases where the IME is running on an older
89                // version of the platform.
90                onChange(self, null);
91            }
92            // The following hook is only available as of API level 16
93            // (Build.VERSION_CODES.JELLY_BEAN), and as such it will only work on JellyBean+
94            // devices. On older versions of the platform, the hook above will be called instead.
95            @Override
96            public void onChange(final boolean self, final Uri uri) {
97                setNeedsToRecreate();
98            }
99        };
100        cres.registerContentObserver(Words.CONTENT_URI, true, mObserver);
101        reloadDictionaryIfRequired();
102    }
103
104    @UsedForTesting
105    public static UserBinaryDictionary getDictionary(final Context context, final Locale locale,
106            final File dictFile, final String dictNamePrefix) {
107        return new UserBinaryDictionary(context, locale, false /* alsoUseMoreRestrictiveLocales */,
108                dictFile, dictNamePrefix + NAME);
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        // Split the locale. For example "en" => ["en"], "de_DE" => ["de", "DE"],
123        // "en_US_foo_bar_qux" => ["en", "US", "foo_bar_qux"] because of the limit of 3.
124        // This is correct for locale processing.
125        // For this example, we'll look at the "en_US_POSIX" case.
126        final String[] localeElements =
127                TextUtils.isEmpty(mLocale) ? new String[] {} : mLocale.split("_", 3);
128        final int length = localeElements.length;
129
130        final StringBuilder request = new StringBuilder("(locale is NULL)");
131        String localeSoFar = "";
132        // At start, localeElements = ["en", "US", "POSIX"] ; localeSoFar = "" ;
133        // and request = "(locale is NULL)"
134        for (int i = 0; i < length; ++i) {
135            // i | localeSoFar    | localeElements
136            // 0 | ""             | ["en", "US", "POSIX"]
137            // 1 | "en_"          | ["en", "US", "POSIX"]
138            // 2 | "en_US_"       | ["en", "en_US", "POSIX"]
139            localeElements[i] = localeSoFar + localeElements[i];
140            localeSoFar = localeElements[i] + "_";
141            // i | request
142            // 0 | "(locale is NULL)"
143            // 1 | "(locale is NULL) or (locale=?)"
144            // 2 | "(locale is NULL) or (locale=?) or (locale=?)"
145            request.append(" or (locale=?)");
146        }
147        // At the end, localeElements = ["en", "en_US", "en_US_POSIX"]; localeSoFar = en_US_POSIX_"
148        // and request = "(locale is NULL) or (locale=?) or (locale=?) or (locale=?)"
149
150        final String[] requestArguments;
151        // If length == 3, we already have all the arguments we need (common prefix is meaningless
152        // inside variants
153        if (mAlsoUseMoreRestrictiveLocales && length < 3) {
154            request.append(" or (locale like ?)");
155            // The following creates an array with one more (null) position
156            final String[] localeElementsWithMoreRestrictiveLocalesIncluded =
157                    Arrays.copyOf(localeElements, length + 1);
158            localeElementsWithMoreRestrictiveLocalesIncluded[length] =
159                    localeElements[length - 1] + "_%";
160            requestArguments = localeElementsWithMoreRestrictiveLocalesIncluded;
161            // If for example localeElements = ["en"]
162            // then requestArguments = ["en", "en_%"]
163            // and request = (locale is NULL) or (locale=?) or (locale like ?)
164            // If localeElements = ["en", "en_US"]
165            // then requestArguments = ["en", "en_US", "en_US_%"]
166        } else {
167            requestArguments = localeElements;
168        }
169        final String requestString = request.toString();
170        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
171            try {
172                addWordsFromProjectionLocked(PROJECTION_QUERY_WITH_SHORTCUT, requestString,
173                        requestArguments);
174            } catch (IllegalArgumentException e) {
175                // This may happen on some non-compliant devices where the declared API is JB+ but
176                // the SHORTCUT column is not present for some reason.
177                addWordsFromProjectionLocked(PROJECTION_QUERY_WITHOUT_SHORTCUT, requestString,
178                        requestArguments);
179            }
180        } else {
181            addWordsFromProjectionLocked(PROJECTION_QUERY_WITHOUT_SHORTCUT, requestString,
182                    requestArguments);
183        }
184    }
185
186    private void addWordsFromProjectionLocked(final String[] query, String request,
187            final String[] requestArguments) throws IllegalArgumentException {
188        Cursor cursor = null;
189        try {
190            cursor = mContext.getContentResolver().query(
191                    Words.CONTENT_URI, query, request, requestArguments, null);
192            addWordsLocked(cursor);
193        } catch (final SQLiteException e) {
194            Log.e(TAG, "SQLiteException in the remote User dictionary process.", e);
195        } finally {
196            try {
197                if (null != cursor) cursor.close();
198            } catch (final SQLiteException e) {
199                Log.e(TAG, "SQLiteException in the remote User dictionary process.", e);
200            }
201        }
202    }
203
204    public static boolean isEnabled(final Context context) {
205        final ContentResolver cr = context.getContentResolver();
206        final ContentProviderClient client = cr.acquireContentProviderClient(Words.CONTENT_URI);
207        if (client != null) {
208            client.release();
209            return true;
210        } else {
211            return false;
212        }
213    }
214
215    /**
216     * Adds a word to the user dictionary and makes it persistent.
217     *
218     * @param context the context
219     * @param locale the locale
220     * @param word the word to add. If the word is capitalized, then the dictionary will
221     * recognize it as a capitalized word when searched.
222     */
223    public static void addWordToUserDictionary(final Context context, final Locale locale,
224            final String word) {
225        // Update the user dictionary provider
226        UserDictionaryCompatUtils.addWord(context, word,
227                HISTORICAL_DEFAULT_USER_DICTIONARY_FREQUENCY, null, locale);
228    }
229
230    private int scaleFrequencyFromDefaultToLatinIme(final int defaultFrequency) {
231        // The default frequency for the user dictionary is 250 for historical reasons.
232        // Latin IME considers a good value for the default user dictionary frequency
233        // is about 160 considering the scale we use. So we are scaling down the values.
234        if (defaultFrequency > Integer.MAX_VALUE / LATINIME_DEFAULT_USER_DICTIONARY_FREQUENCY) {
235            return (defaultFrequency / HISTORICAL_DEFAULT_USER_DICTIONARY_FREQUENCY)
236                    * LATINIME_DEFAULT_USER_DICTIONARY_FREQUENCY;
237        } else {
238            return (defaultFrequency * LATINIME_DEFAULT_USER_DICTIONARY_FREQUENCY)
239                    / HISTORICAL_DEFAULT_USER_DICTIONARY_FREQUENCY;
240        }
241    }
242
243    private void addWordsLocked(final Cursor cursor) {
244        final boolean hasShortcutColumn = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN;
245        if (cursor == null) return;
246        if (cursor.moveToFirst()) {
247            final int indexWord = cursor.getColumnIndex(Words.WORD);
248            final int indexShortcut = hasShortcutColumn ? cursor.getColumnIndex(Words.SHORTCUT) : 0;
249            final int indexFrequency = cursor.getColumnIndex(Words.FREQUENCY);
250            while (!cursor.isAfterLast()) {
251                final String word = cursor.getString(indexWord);
252                final String shortcut = hasShortcutColumn ? cursor.getString(indexShortcut) : null;
253                final int frequency = cursor.getInt(indexFrequency);
254                final int adjustedFrequency = scaleFrequencyFromDefaultToLatinIme(frequency);
255                // Safeguard against adding really long words.
256                if (word.length() <= MAX_WORD_LENGTH) {
257                    runGCIfRequiredLocked(true /* mindsBlockByGC */);
258                    addUnigramLocked(word, adjustedFrequency, null /* shortcutTarget */,
259                            0 /* shortcutFreq */, false /* isNotAWord */,
260                            false /* isBlacklisted */, BinaryDictionary.NOT_A_VALID_TIMESTAMP);
261                    if (null != shortcut && shortcut.length() <= MAX_WORD_LENGTH) {
262                        runGCIfRequiredLocked(true /* mindsBlockByGC */);
263                        addUnigramLocked(shortcut, adjustedFrequency, word,
264                                USER_DICT_SHORTCUT_FREQUENCY, true /* isNotAWord */,
265                                false /* isBlacklisted */, BinaryDictionary.NOT_A_VALID_TIMESTAMP);
266                    }
267                }
268                cursor.moveToNext();
269            }
270        }
271    }
272}
273