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 
17 package com.android.inputmethod.latin;
18 
19 import android.content.ContentProviderClient;
20 import android.content.ContentResolver;
21 import android.content.Context;
22 import android.database.ContentObserver;
23 import android.database.Cursor;
24 import android.database.sqlite.SQLiteException;
25 import android.net.Uri;
26 import android.os.Build;
27 import android.provider.UserDictionary.Words;
28 import android.text.TextUtils;
29 import android.util.Log;
30 
31 import com.android.inputmethod.annotations.UsedForTesting;
32 import com.android.inputmethod.compat.UserDictionaryCompatUtils;
33 import com.android.inputmethod.latin.utils.SubtypeLocaleUtils;
34 
35 import java.io.File;
36 import java.util.Arrays;
37 import 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  */
43 public 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 
UserBinaryDictionary(final Context context, final Locale locale, final boolean alsoUseMoreRestrictiveLocales, final File dictFile, final String name)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
getDictionary(final Context context, final Locale locale, final File dictFile, final String dictNamePrefix)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
close()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
loadInitialContentsLocked()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 
addWordsFromProjectionLocked(final String[] query, String request, final String[] requestArguments)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 
isEnabled(final Context context)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      */
addWordToUserDictionary(final Context context, final Locale locale, final String word)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 
scaleFrequencyFromDefaultToLatinIme(final int defaultFrequency)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 
addWordsLocked(final Cursor cursor)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