aboutsummaryrefslogtreecommitdiffstats
path: root/java/src/org/kelar/inputmethod/latin/spellcheck
diff options
context:
space:
mode:
authorAmin Bandali <bandali@kelar.org>2024-12-16 21:45:41 -0500
committerAmin Bandali <bandali@kelar.org>2025-01-11 14:17:35 -0500
commite9a0e66716dab4dd3184d009d8920de1961efdfa (patch)
tree02dcc096643d74645bf28459c2834c3d4a2ad7f2 /java/src/org/kelar/inputmethod/latin/spellcheck
parentfb3b9360d70596d7e921de8bf7d3ca99564a077e (diff)
downloadlatinime-e9a0e66716dab4dd3184d009d8920de1961efdfa.tar.gz
latinime-e9a0e66716dab4dd3184d009d8920de1961efdfa.tar.xz
latinime-e9a0e66716dab4dd3184d009d8920de1961efdfa.zip
Rename to Kelar Keyboard (org.kelar.inputmethod.latin)
Diffstat (limited to 'java/src/org/kelar/inputmethod/latin/spellcheck')
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java244
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java225
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSessionFactory.java25
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java390
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/SentenceLevelAdapter.java197
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java61
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsFragment.java90
7 files changed, 1232 insertions, 0 deletions
diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java
new file mode 100644
index 000000000..fb53b92d7
--- /dev/null
+++ b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.kelar.inputmethod.latin.spellcheck;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.service.textservice.SpellCheckerService;
+import android.text.InputType;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodSubtype;
+import android.view.textservice.SuggestionsInfo;
+
+import org.kelar.inputmethod.keyboard.Keyboard;
+import org.kelar.inputmethod.keyboard.KeyboardId;
+import org.kelar.inputmethod.keyboard.KeyboardLayoutSet;
+import org.kelar.inputmethod.latin.DictionaryFacilitator;
+import org.kelar.inputmethod.latin.DictionaryFacilitatorLruCache;
+import org.kelar.inputmethod.latin.NgramContext;
+import org.kelar.inputmethod.latin.R;
+import org.kelar.inputmethod.latin.RichInputMethodSubtype;
+import org.kelar.inputmethod.latin.SuggestedWords;
+import org.kelar.inputmethod.latin.common.ComposedData;
+import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion;
+import org.kelar.inputmethod.latin.utils.AdditionalSubtypeUtils;
+import org.kelar.inputmethod.latin.utils.ScriptUtils;
+import org.kelar.inputmethod.latin.utils.SuggestionResults;
+
+import java.util.Locale;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.Semaphore;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Service for spell checking, using LatinIME's dictionaries and mechanisms.
+ */
+public final class AndroidSpellCheckerService extends SpellCheckerService
+ implements SharedPreferences.OnSharedPreferenceChangeListener {
+ private static final String TAG = AndroidSpellCheckerService.class.getSimpleName();
+ private static final boolean DEBUG = false;
+
+ public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts";
+
+ private static final int SPELLCHECKER_DUMMY_KEYBOARD_WIDTH = 480;
+ private static final int SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT = 301;
+
+ private static final String DICTIONARY_NAME_PREFIX = "spellcheck_";
+
+ private static final String[] EMPTY_STRING_ARRAY = new String[0];
+
+ private final int MAX_NUM_OF_THREADS_READ_DICTIONARY = 2;
+ private final Semaphore mSemaphore = new Semaphore(MAX_NUM_OF_THREADS_READ_DICTIONARY,
+ true /* fair */);
+ // TODO: Make each spell checker session has its own session id.
+ private final ConcurrentLinkedQueue<Integer> mSessionIdPool = new ConcurrentLinkedQueue<>();
+
+ private final DictionaryFacilitatorLruCache mDictionaryFacilitatorCache =
+ new DictionaryFacilitatorLruCache(this /* context */, DICTIONARY_NAME_PREFIX);
+ private final ConcurrentHashMap<Locale, Keyboard> mKeyboardCache = new ConcurrentHashMap<>();
+
+ // The threshold for a suggestion to be considered "recommended".
+ private float mRecommendedThreshold;
+ // TODO: make a spell checker option to block offensive words or not
+ private final SettingsValuesForSuggestion mSettingsValuesForSuggestion =
+ new SettingsValuesForSuggestion(true /* blockPotentiallyOffensive */);
+
+ public static final String SINGLE_QUOTE = "\u0027";
+ public static final String APOSTROPHE = "\u2019";
+
+ public AndroidSpellCheckerService() {
+ super();
+ for (int i = 0; i < MAX_NUM_OF_THREADS_READ_DICTIONARY; i++) {
+ mSessionIdPool.add(i);
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ mRecommendedThreshold = Float.parseFloat(
+ getString(R.string.spellchecker_recommended_threshold_value));
+ final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
+ prefs.registerOnSharedPreferenceChangeListener(this);
+ onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY);
+ }
+
+ public float getRecommendedThreshold() {
+ return mRecommendedThreshold;
+ }
+
+ private static String getKeyboardLayoutNameForLocale(final Locale locale) {
+ // See b/19963288.
+ if (locale.getLanguage().equals("sr")) {
+ return "south_slavic";
+ }
+ final int script = ScriptUtils.getScriptFromSpellCheckerLocale(locale);
+ switch (script) {
+ case ScriptUtils.SCRIPT_LATIN:
+ return "qwerty";
+ case ScriptUtils.SCRIPT_CYRILLIC:
+ return "east_slavic";
+ case ScriptUtils.SCRIPT_GREEK:
+ return "greek";
+ case ScriptUtils.SCRIPT_HEBREW:
+ return "hebrew";
+ default:
+ throw new RuntimeException("Wrong script supplied: " + script);
+ }
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
+ if (!PREF_USE_CONTACTS_KEY.equals(key)) return;
+ final boolean useContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true);
+ mDictionaryFacilitatorCache.setUseContactsDictionary(useContactsDictionary);
+ }
+
+ @Override
+ public Session createSession() {
+ // Should not refer to AndroidSpellCheckerSession directly considering
+ // that AndroidSpellCheckerSession may be overlaid.
+ return AndroidSpellCheckerSessionFactory.newInstance(this);
+ }
+
+ /**
+ * Returns an empty SuggestionsInfo with flags signaling the word is not in the dictionary.
+ * @param reportAsTypo whether this should include the flag LOOKS_LIKE_TYPO, for red underline.
+ * @return the empty SuggestionsInfo with the appropriate flags set.
+ */
+ public static SuggestionsInfo getNotInDictEmptySuggestions(final boolean reportAsTypo) {
+ return new SuggestionsInfo(reportAsTypo ? SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO : 0,
+ EMPTY_STRING_ARRAY);
+ }
+
+ /**
+ * Returns an empty suggestionInfo with flags signaling the word is in the dictionary.
+ * @return the empty SuggestionsInfo with the appropriate flags set.
+ */
+ public static SuggestionsInfo getInDictEmptySuggestions() {
+ return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY,
+ EMPTY_STRING_ARRAY);
+ }
+
+ public boolean isValidWord(final Locale locale, final String word) {
+ mSemaphore.acquireUninterruptibly();
+ try {
+ DictionaryFacilitator dictionaryFacilitatorForLocale =
+ mDictionaryFacilitatorCache.get(locale);
+ return dictionaryFacilitatorForLocale.isValidSpellingWord(word);
+ } finally {
+ mSemaphore.release();
+ }
+ }
+
+ public SuggestionResults getSuggestionResults(final Locale locale,
+ final ComposedData composedData, final NgramContext ngramContext,
+ @Nonnull final Keyboard keyboard) {
+ Integer sessionId = null;
+ mSemaphore.acquireUninterruptibly();
+ try {
+ sessionId = mSessionIdPool.poll();
+ DictionaryFacilitator dictionaryFacilitatorForLocale =
+ mDictionaryFacilitatorCache.get(locale);
+ return dictionaryFacilitatorForLocale.getSuggestionResults(composedData, ngramContext,
+ keyboard, mSettingsValuesForSuggestion,
+ sessionId, SuggestedWords.INPUT_STYLE_TYPING);
+ } finally {
+ if (sessionId != null) {
+ mSessionIdPool.add(sessionId);
+ }
+ mSemaphore.release();
+ }
+ }
+
+ public boolean hasMainDictionaryForLocale(final Locale locale) {
+ mSemaphore.acquireUninterruptibly();
+ try {
+ final DictionaryFacilitator dictionaryFacilitator =
+ mDictionaryFacilitatorCache.get(locale);
+ return dictionaryFacilitator.hasAtLeastOneInitializedMainDictionary();
+ } finally {
+ mSemaphore.release();
+ }
+ }
+
+ @Override
+ public boolean onUnbind(final Intent intent) {
+ mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY);
+ try {
+ mDictionaryFacilitatorCache.closeDictionaries();
+ } finally {
+ mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY);
+ }
+ mKeyboardCache.clear();
+ return false;
+ }
+
+ public Keyboard getKeyboardForLocale(final Locale locale) {
+ Keyboard keyboard = mKeyboardCache.get(locale);
+ if (keyboard == null) {
+ keyboard = createKeyboardForLocale(locale);
+ if (keyboard != null) {
+ mKeyboardCache.put(locale, keyboard);
+ }
+ }
+ return keyboard;
+ }
+
+ private Keyboard createKeyboardForLocale(final Locale locale) {
+ final String keyboardLayoutName = getKeyboardLayoutNameForLocale(locale);
+ final InputMethodSubtype subtype = AdditionalSubtypeUtils.createDummyAdditionalSubtype(
+ locale.toString(), keyboardLayoutName);
+ final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype);
+ return keyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET);
+ }
+
+ private KeyboardLayoutSet createKeyboardSetForSpellChecker(final InputMethodSubtype subtype) {
+ final EditorInfo editorInfo = new EditorInfo();
+ editorInfo.inputType = InputType.TYPE_CLASS_TEXT;
+ final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo);
+ builder.setKeyboardGeometry(
+ SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT);
+ builder.setSubtype(RichInputMethodSubtype.getRichInputMethodSubtype(subtype));
+ builder.setIsSpellChecker(true /* isSpellChecker */);
+ builder.disableTouchPositionCorrectionData();
+ return builder.build();
+ }
+}
diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java
new file mode 100644
index 000000000..3ab5138bf
--- /dev/null
+++ b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java
@@ -0,0 +1,225 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.kelar.inputmethod.latin.spellcheck;
+
+import android.annotation.TargetApi;
+import android.content.res.Resources;
+import android.os.Binder;
+import android.os.Build;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.textservice.SentenceSuggestionsInfo;
+import android.view.textservice.SuggestionsInfo;
+import android.view.textservice.TextInfo;
+
+import org.kelar.inputmethod.compat.TextInfoCompatUtils;
+import org.kelar.inputmethod.latin.NgramContext;
+import org.kelar.inputmethod.latin.utils.SpannableStringUtils;
+
+import java.util.ArrayList;
+import java.util.Locale;
+
+public final class AndroidSpellCheckerSession extends AndroidWordLevelSpellCheckerSession {
+ private static final String TAG = AndroidSpellCheckerSession.class.getSimpleName();
+ private static final boolean DBG = false;
+ private final Resources mResources;
+ private SentenceLevelAdapter mSentenceLevelAdapter;
+
+ public AndroidSpellCheckerSession(AndroidSpellCheckerService service) {
+ super(service);
+ mResources = service.getResources();
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ private SentenceSuggestionsInfo fixWronglyInvalidatedWordWithSingleQuote(TextInfo ti,
+ SentenceSuggestionsInfo ssi) {
+ final CharSequence typedText = TextInfoCompatUtils.getCharSequenceOrString(ti);
+ if (!typedText.toString().contains(AndroidSpellCheckerService.SINGLE_QUOTE)) {
+ return null;
+ }
+ final int N = ssi.getSuggestionsCount();
+ final ArrayList<Integer> additionalOffsets = new ArrayList<>();
+ final ArrayList<Integer> additionalLengths = new ArrayList<>();
+ final ArrayList<SuggestionsInfo> additionalSuggestionsInfos = new ArrayList<>();
+ CharSequence currentWord = null;
+ for (int i = 0; i < N; ++i) {
+ final SuggestionsInfo si = ssi.getSuggestionsInfoAt(i);
+ final int flags = si.getSuggestionsAttributes();
+ if ((flags & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) == 0) {
+ continue;
+ }
+ final int offset = ssi.getOffsetAt(i);
+ final int length = ssi.getLengthAt(i);
+ final CharSequence subText = typedText.subSequence(offset, offset + length);
+ final NgramContext ngramContext =
+ new NgramContext(new NgramContext.WordInfo(currentWord));
+ currentWord = subText;
+ if (!subText.toString().contains(AndroidSpellCheckerService.SINGLE_QUOTE)) {
+ continue;
+ }
+ // Split preserving spans.
+ final CharSequence[] splitTexts = SpannableStringUtils.split(subText,
+ AndroidSpellCheckerService.SINGLE_QUOTE,
+ true /* preserveTrailingEmptySegments */);
+ if (splitTexts == null || splitTexts.length <= 1) {
+ continue;
+ }
+ final int splitNum = splitTexts.length;
+ for (int j = 0; j < splitNum; ++j) {
+ final CharSequence splitText = splitTexts[j];
+ if (TextUtils.isEmpty(splitText)) {
+ continue;
+ }
+ if (mSuggestionsCache.getSuggestionsFromCache(splitText.toString()) == null) {
+ continue;
+ }
+ final int newLength = splitText.length();
+ // Neither RESULT_ATTR_IN_THE_DICTIONARY nor RESULT_ATTR_LOOKS_LIKE_TYPO
+ final int newFlags = 0;
+ final SuggestionsInfo newSi = new SuggestionsInfo(newFlags, EMPTY_STRING_ARRAY);
+ newSi.setCookieAndSequence(si.getCookie(), si.getSequence());
+ if (DBG) {
+ Log.d(TAG, "Override and remove old span over: " + splitText + ", "
+ + offset + "," + newLength);
+ }
+ additionalOffsets.add(offset);
+ additionalLengths.add(newLength);
+ additionalSuggestionsInfos.add(newSi);
+ }
+ }
+ final int additionalSize = additionalOffsets.size();
+ if (additionalSize <= 0) {
+ return null;
+ }
+ final int suggestionsSize = N + additionalSize;
+ final int[] newOffsets = new int[suggestionsSize];
+ final int[] newLengths = new int[suggestionsSize];
+ final SuggestionsInfo[] newSuggestionsInfos = new SuggestionsInfo[suggestionsSize];
+ int i;
+ for (i = 0; i < N; ++i) {
+ newOffsets[i] = ssi.getOffsetAt(i);
+ newLengths[i] = ssi.getLengthAt(i);
+ newSuggestionsInfos[i] = ssi.getSuggestionsInfoAt(i);
+ }
+ for (; i < suggestionsSize; ++i) {
+ newOffsets[i] = additionalOffsets.get(i - N);
+ newLengths[i] = additionalLengths.get(i - N);
+ newSuggestionsInfos[i] = additionalSuggestionsInfos.get(i - N);
+ }
+ return new SentenceSuggestionsInfo(newSuggestionsInfos, newOffsets, newLengths);
+ }
+
+ @Override
+ public SentenceSuggestionsInfo[] onGetSentenceSuggestionsMultiple(TextInfo[] textInfos,
+ int suggestionsLimit) {
+ final SentenceSuggestionsInfo[] retval = splitAndSuggest(textInfos, suggestionsLimit);
+ if (retval == null || retval.length != textInfos.length) {
+ return retval;
+ }
+ for (int i = 0; i < retval.length; ++i) {
+ final SentenceSuggestionsInfo tempSsi =
+ fixWronglyInvalidatedWordWithSingleQuote(textInfos[i], retval[i]);
+ if (tempSsi != null) {
+ retval[i] = tempSsi;
+ }
+ }
+ return retval;
+ }
+
+ /**
+ * Get sentence suggestions for specified texts in an array of TextInfo. This is taken from
+ * SpellCheckerService#onGetSentenceSuggestionsMultiple that we can't use because it's
+ * using private variables.
+ * The default implementation splits the input text to words and returns
+ * {@link SentenceSuggestionsInfo} which contains suggestions for each word.
+ * This function will run on the incoming IPC thread.
+ * So, this is not called on the main thread,
+ * but will be called in series on another thread.
+ * @param textInfos an array of the text metadata
+ * @param suggestionsLimit the maximum number of suggestions to be returned
+ * @return an array of {@link SentenceSuggestionsInfo} returned by
+ * {@link android.service.textservice.SpellCheckerService.Session#onGetSuggestions(TextInfo, int)}
+ */
+ private SentenceSuggestionsInfo[] splitAndSuggest(TextInfo[] textInfos, int suggestionsLimit) {
+ if (textInfos == null || textInfos.length == 0) {
+ return SentenceLevelAdapter.getEmptySentenceSuggestionsInfo();
+ }
+ SentenceLevelAdapter sentenceLevelAdapter;
+ synchronized(this) {
+ sentenceLevelAdapter = mSentenceLevelAdapter;
+ if (sentenceLevelAdapter == null) {
+ final String localeStr = getLocale();
+ if (!TextUtils.isEmpty(localeStr)) {
+ sentenceLevelAdapter = new SentenceLevelAdapter(mResources,
+ new Locale(localeStr));
+ mSentenceLevelAdapter = sentenceLevelAdapter;
+ }
+ }
+ }
+ if (sentenceLevelAdapter == null) {
+ return SentenceLevelAdapter.getEmptySentenceSuggestionsInfo();
+ }
+ final int infosSize = textInfos.length;
+ final SentenceSuggestionsInfo[] retval = new SentenceSuggestionsInfo[infosSize];
+ for (int i = 0; i < infosSize; ++i) {
+ final SentenceLevelAdapter.SentenceTextInfoParams textInfoParams =
+ sentenceLevelAdapter.getSplitWords(textInfos[i]);
+ final ArrayList<SentenceLevelAdapter.SentenceWordItem> mItems =
+ textInfoParams.mItems;
+ final int itemsSize = mItems.size();
+ final TextInfo[] splitTextInfos = new TextInfo[itemsSize];
+ for (int j = 0; j < itemsSize; ++j) {
+ splitTextInfos[j] = mItems.get(j).mTextInfo;
+ }
+ retval[i] = SentenceLevelAdapter.reconstructSuggestions(
+ textInfoParams, onGetSuggestionsMultiple(
+ splitTextInfos, suggestionsLimit, true));
+ }
+ return retval;
+ }
+
+ @Override
+ public SuggestionsInfo[] onGetSuggestionsMultiple(TextInfo[] textInfos,
+ int suggestionsLimit, boolean sequentialWords) {
+ long ident = Binder.clearCallingIdentity();
+ try {
+ final int length = textInfos.length;
+ final SuggestionsInfo[] retval = new SuggestionsInfo[length];
+ for (int i = 0; i < length; ++i) {
+ final CharSequence prevWord;
+ if (sequentialWords && i > 0) {
+ final TextInfo prevTextInfo = textInfos[i - 1];
+ final CharSequence prevWordCandidate =
+ TextInfoCompatUtils.getCharSequenceOrString(prevTextInfo);
+ // Note that an empty string would be used to indicate the initial word
+ // in the future.
+ prevWord = TextUtils.isEmpty(prevWordCandidate) ? null : prevWordCandidate;
+ } else {
+ prevWord = null;
+ }
+ final NgramContext ngramContext =
+ new NgramContext(new NgramContext.WordInfo(prevWord));
+ final TextInfo textInfo = textInfos[i];
+ retval[i] = onGetSuggestionsInternal(textInfo, ngramContext, suggestionsLimit);
+ retval[i].setCookieAndSequence(textInfo.getCookie(), textInfo.getSequence());
+ }
+ return retval;
+ } finally {
+ Binder.restoreCallingIdentity(ident);
+ }
+ }
+}
diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSessionFactory.java b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSessionFactory.java
new file mode 100644
index 000000000..9463a8fad
--- /dev/null
+++ b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSessionFactory.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.kelar.inputmethod.latin.spellcheck;
+
+import android.service.textservice.SpellCheckerService.Session;
+
+public abstract class AndroidSpellCheckerSessionFactory {
+ public static Session newInstance(AndroidSpellCheckerService service) {
+ return new AndroidSpellCheckerSession(service);
+ }
+}
diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java
new file mode 100644
index 000000000..2f1fc868b
--- /dev/null
+++ b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java
@@ -0,0 +1,390 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.kelar.inputmethod.latin.spellcheck;
+
+import android.content.ContentResolver;
+import android.database.ContentObserver;
+import android.os.Binder;
+import android.provider.UserDictionary.Words;
+import android.service.textservice.SpellCheckerService.Session;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.LruCache;
+import android.view.textservice.SuggestionsInfo;
+import android.view.textservice.TextInfo;
+
+import org.kelar.inputmethod.compat.SuggestionsInfoCompatUtils;
+import org.kelar.inputmethod.keyboard.Keyboard;
+import org.kelar.inputmethod.latin.NgramContext;
+import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
+import org.kelar.inputmethod.latin.WordComposer;
+import org.kelar.inputmethod.latin.common.Constants;
+import org.kelar.inputmethod.latin.common.LocaleUtils;
+import org.kelar.inputmethod.latin.common.StringUtils;
+import org.kelar.inputmethod.latin.define.DebugFlags;
+import org.kelar.inputmethod.latin.utils.BinaryDictionaryUtils;
+import org.kelar.inputmethod.latin.utils.ScriptUtils;
+import org.kelar.inputmethod.latin.utils.StatsUtils;
+import org.kelar.inputmethod.latin.utils.SuggestionResults;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public abstract class AndroidWordLevelSpellCheckerSession extends Session {
+ private static final String TAG = AndroidWordLevelSpellCheckerSession.class.getSimpleName();
+
+ public final static String[] EMPTY_STRING_ARRAY = new String[0];
+
+ // Immutable, but not available in the constructor.
+ private Locale mLocale;
+ // Cache this for performance
+ private int mScript; // One of SCRIPT_LATIN or SCRIPT_CYRILLIC for now.
+ private final AndroidSpellCheckerService mService;
+ protected final SuggestionsCache mSuggestionsCache = new SuggestionsCache();
+ private final ContentObserver mObserver;
+
+ private static final String quotesRegexp =
+ "(\\u0022|\\u0027|\\u0060|\\u00B4|\\u2018|\\u2018|\\u201C|\\u201D)";
+
+ private static final class SuggestionsParams {
+ public final String[] mSuggestions;
+ public final int mFlags;
+ public SuggestionsParams(String[] suggestions, int flags) {
+ mSuggestions = suggestions;
+ mFlags = flags;
+ }
+ }
+
+ protected static final class SuggestionsCache {
+ private static final int MAX_CACHE_SIZE = 50;
+ private final LruCache<String, SuggestionsParams> mUnigramSuggestionsInfoCache =
+ new LruCache<>(MAX_CACHE_SIZE);
+
+ private static String generateKey(final String query) {
+ return query + "";
+ }
+
+ public SuggestionsParams getSuggestionsFromCache(final String query) {
+ return mUnigramSuggestionsInfoCache.get(query);
+ }
+
+ public void putSuggestionsToCache(
+ final String query, final String[] suggestions, final int flags) {
+ if (suggestions == null || TextUtils.isEmpty(query)) {
+ return;
+ }
+ mUnigramSuggestionsInfoCache.put(
+ generateKey(query),
+ new SuggestionsParams(suggestions, flags));
+ }
+
+ public void clearCache() {
+ mUnigramSuggestionsInfoCache.evictAll();
+ }
+ }
+
+ AndroidWordLevelSpellCheckerSession(final AndroidSpellCheckerService service) {
+ mService = service;
+ final ContentResolver cres = service.getContentResolver();
+
+ mObserver = new ContentObserver(null) {
+ @Override
+ public void onChange(boolean self) {
+ mSuggestionsCache.clearCache();
+ }
+ };
+ cres.registerContentObserver(Words.CONTENT_URI, true, mObserver);
+ }
+
+ @Override
+ public void onCreate() {
+ final String localeString = getLocale();
+ mLocale = (null == localeString) ? null
+ : LocaleUtils.constructLocaleFromString(localeString);
+ mScript = ScriptUtils.getScriptFromSpellCheckerLocale(mLocale);
+ }
+
+ @Override
+ public void onClose() {
+ final ContentResolver cres = mService.getContentResolver();
+ cres.unregisterContentObserver(mObserver);
+ }
+
+ private static final int CHECKABILITY_CHECKABLE = 0;
+ private static final int CHECKABILITY_TOO_MANY_NON_LETTERS = 1;
+ private static final int CHECKABILITY_CONTAINS_PERIOD = 2;
+ private static final int CHECKABILITY_EMAIL_OR_URL = 3;
+ private static final int CHECKABILITY_FIRST_LETTER_UNCHECKABLE = 4;
+ private static final int CHECKABILITY_TOO_SHORT = 5;
+ /**
+ * Finds out whether a particular string should be filtered out of spell checking.
+ *
+ * This will loosely match URLs, numbers, symbols. To avoid always underlining words that
+ * we know we will never recognize, this accepts a script identifier that should be one
+ * of the SCRIPT_* constants defined above, to rule out quickly characters from very
+ * different languages.
+ *
+ * @param text the string to evaluate.
+ * @param script the identifier for the script this spell checker recognizes
+ * @return one of the FILTER_OUT_* constants above.
+ */
+ private static int getCheckabilityInScript(final String text, final int script) {
+ if (TextUtils.isEmpty(text) || text.length() <= 1) return CHECKABILITY_TOO_SHORT;
+
+ // TODO: check if an equivalent processing can't be done more quickly with a
+ // compiled regexp.
+ // Filter by first letter
+ final int firstCodePoint = text.codePointAt(0);
+ // Filter out words that don't start with a letter or an apostrophe
+ if (!ScriptUtils.isLetterPartOfScript(firstCodePoint, script)
+ && '\'' != firstCodePoint) return CHECKABILITY_FIRST_LETTER_UNCHECKABLE;
+
+ // Filter contents
+ final int length = text.length();
+ int letterCount = 0;
+ for (int i = 0; i < length; i = text.offsetByCodePoints(i, 1)) {
+ final int codePoint = text.codePointAt(i);
+ // Any word containing a COMMERCIAL_AT is probably an e-mail address
+ // Any word containing a SLASH is probably either an ad-hoc combination of two
+ // words or a URI - in either case we don't want to spell check that
+ if (Constants.CODE_COMMERCIAL_AT == codePoint || Constants.CODE_SLASH == codePoint) {
+ return CHECKABILITY_EMAIL_OR_URL;
+ }
+ // If the string contains a period, native returns strange suggestions (it seems
+ // to return suggestions for everything up to the period only and to ignore the
+ // rest), so we suppress lookup if there is a period.
+ // TODO: investigate why native returns these suggestions and remove this code.
+ if (Constants.CODE_PERIOD == codePoint) {
+ return CHECKABILITY_CONTAINS_PERIOD;
+ }
+ if (ScriptUtils.isLetterPartOfScript(codePoint, script)) ++letterCount;
+ }
+ // Guestimate heuristic: perform spell checking if at least 3/4 of the characters
+ // in this word are letters
+ return (letterCount * 4 < length * 3)
+ ? CHECKABILITY_TOO_MANY_NON_LETTERS : CHECKABILITY_CHECKABLE;
+ }
+
+ /**
+ * Helper method to test valid capitalizations of a word.
+ *
+ * If the "text" is lower-case, we test only the exact string.
+ * If the "Text" is capitalized, we test the exact string "Text" and the lower-cased
+ * version of it "text".
+ * If the "TEXT" is fully upper case, we test the exact string "TEXT", the lower-cased
+ * version of it "text" and the capitalized version of it "Text".
+ */
+ private boolean isInDictForAnyCapitalization(final String text, final int capitalizeType) {
+ // If the word is in there as is, then it's in the dictionary. If not, we'll test lower
+ // case versions, but only if the word is not already all-lower case or mixed case.
+ if (mService.isValidWord(mLocale, text)) return true;
+ if (StringUtils.CAPITALIZE_NONE == capitalizeType) return false;
+
+ // If we come here, we have a capitalized word (either First- or All-).
+ // Downcase the word and look it up again. If the word is only capitalized, we
+ // tested all possibilities, so if it's still negative we can return false.
+ final String lowerCaseText = text.toLowerCase(mLocale);
+ if (mService.isValidWord(mLocale, lowerCaseText)) return true;
+ if (StringUtils.CAPITALIZE_FIRST == capitalizeType) return false;
+
+ // If the lower case version is not in the dictionary, it's still possible
+ // that we have an all-caps version of a word that needs to be capitalized
+ // according to the dictionary. E.g. "GERMANS" only exists in the dictionary as "Germans".
+ return mService.isValidWord(mLocale,
+ StringUtils.capitalizeFirstAndDowncaseRest(lowerCaseText, mLocale));
+ }
+
+ // Note : this must be reentrant
+ /**
+ * Gets a list of suggestions for a specific string. This returns a list of possible
+ * corrections for the text passed as an argument. It may split or group words, and
+ * even perform grammatical analysis.
+ */
+ private SuggestionsInfo onGetSuggestionsInternal(final TextInfo textInfo,
+ final int suggestionsLimit) {
+ return onGetSuggestionsInternal(textInfo, null, suggestionsLimit);
+ }
+
+ protected SuggestionsInfo onGetSuggestionsInternal(
+ final TextInfo textInfo, final NgramContext ngramContext, final int suggestionsLimit) {
+ try {
+ final String text = textInfo.getText().
+ replaceAll(AndroidSpellCheckerService.APOSTROPHE,
+ AndroidSpellCheckerService.SINGLE_QUOTE).
+ replaceAll("^" + quotesRegexp, "").
+ replaceAll(quotesRegexp + "$", "");
+
+ if (!mService.hasMainDictionaryForLocale(mLocale)) {
+ return AndroidSpellCheckerService.getNotInDictEmptySuggestions(
+ false /* reportAsTypo */);
+ }
+
+ // Handle special patterns like email, URI, telephone number.
+ final int checkability = getCheckabilityInScript(text, mScript);
+ if (CHECKABILITY_CHECKABLE != checkability) {
+ if (CHECKABILITY_CONTAINS_PERIOD == checkability) {
+ final String[] splitText = text.split(Constants.REGEXP_PERIOD);
+ boolean allWordsAreValid = true;
+ for (final String word : splitText) {
+ if (!mService.isValidWord(mLocale, word)) {
+ allWordsAreValid = false;
+ break;
+ }
+ }
+ if (allWordsAreValid) {
+ return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO
+ | SuggestionsInfo.RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS,
+ new String[] {
+ TextUtils.join(Constants.STRING_SPACE, splitText) });
+ }
+ }
+ return mService.isValidWord(mLocale, text) ?
+ AndroidSpellCheckerService.getInDictEmptySuggestions() :
+ AndroidSpellCheckerService.getNotInDictEmptySuggestions(
+ CHECKABILITY_CONTAINS_PERIOD == checkability /* reportAsTypo */);
+ }
+
+ // Handle normal words.
+ final int capitalizeType = StringUtils.getCapitalizationType(text);
+
+ if (isInDictForAnyCapitalization(text, capitalizeType)) {
+ if (DebugFlags.DEBUG_ENABLED) {
+ Log.i(TAG, "onGetSuggestionsInternal() : [" + text + "] is a valid word");
+ }
+ return AndroidSpellCheckerService.getInDictEmptySuggestions();
+ }
+ if (DebugFlags.DEBUG_ENABLED) {
+ Log.i(TAG, "onGetSuggestionsInternal() : [" + text + "] is NOT a valid word");
+ }
+
+ final Keyboard keyboard = mService.getKeyboardForLocale(mLocale);
+ if (null == keyboard) {
+ Log.w(TAG, "onGetSuggestionsInternal() : No keyboard for locale: " + mLocale);
+ // If there is no keyboard for this locale, don't do any spell-checking.
+ return AndroidSpellCheckerService.getNotInDictEmptySuggestions(
+ false /* reportAsTypo */);
+ }
+
+ final WordComposer composer = new WordComposer();
+ final int[] codePoints = StringUtils.toCodePointArray(text);
+ final int[] coordinates;
+ coordinates = keyboard.getCoordinates(codePoints);
+ composer.setComposingWord(codePoints, coordinates);
+ // TODO: Don't gather suggestions if the limit is <= 0 unless necessary
+ final SuggestionResults suggestionResults = mService.getSuggestionResults(
+ mLocale, composer.getComposedDataSnapshot(), ngramContext, keyboard);
+ final Result result = getResult(capitalizeType, mLocale, suggestionsLimit,
+ mService.getRecommendedThreshold(), text, suggestionResults);
+ if (DebugFlags.DEBUG_ENABLED) {
+ if (result.mSuggestions != null && result.mSuggestions.length > 0) {
+ final StringBuilder builder = new StringBuilder();
+ for (String suggestion : result.mSuggestions) {
+ builder.append(" [");
+ builder.append(suggestion);
+ builder.append("]");
+ }
+ Log.i(TAG, "onGetSuggestionsInternal() : Suggestions =" + builder);
+ }
+ }
+ // Handle word not in dictionary.
+ // This is called only once per unique word, so entering multiple
+ // instances of the same word does not result in more than one call
+ // to this method.
+ // Also, upon changing the orientation of the device, this is called
+ // again for every unique invalid word in the text box.
+ StatsUtils.onInvalidWordIdentification(text);
+
+ final int flags =
+ SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO
+ | (result.mHasRecommendedSuggestions
+ ? SuggestionsInfoCompatUtils
+ .getValueOf_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS()
+ : 0);
+ final SuggestionsInfo retval = new SuggestionsInfo(flags, result.mSuggestions);
+ mSuggestionsCache.putSuggestionsToCache(text, result.mSuggestions, flags);
+ return retval;
+ } catch (RuntimeException e) {
+ // Don't kill the keyboard if there is a bug in the spell checker
+ Log.e(TAG, "Exception while spellchecking", e);
+ return AndroidSpellCheckerService.getNotInDictEmptySuggestions(
+ false /* reportAsTypo */);
+ }
+ }
+
+ private static final class Result {
+ public final String[] mSuggestions;
+ public final boolean mHasRecommendedSuggestions;
+ public Result(final String[] gatheredSuggestions, final boolean hasRecommendedSuggestions) {
+ mSuggestions = gatheredSuggestions;
+ mHasRecommendedSuggestions = hasRecommendedSuggestions;
+ }
+ }
+
+ private static Result getResult(final int capitalizeType, final Locale locale,
+ final int suggestionsLimit, final float recommendedThreshold, final String originalText,
+ final SuggestionResults suggestionResults) {
+ if (suggestionResults.isEmpty() || suggestionsLimit <= 0) {
+ return new Result(null /* gatheredSuggestions */,
+ false /* hasRecommendedSuggestions */);
+ }
+ final ArrayList<String> suggestions = new ArrayList<>();
+ for (final SuggestedWordInfo suggestedWordInfo : suggestionResults) {
+ final String suggestion;
+ if (StringUtils.CAPITALIZE_ALL == capitalizeType) {
+ suggestion = suggestedWordInfo.mWord.toUpperCase(locale);
+ } else if (StringUtils.CAPITALIZE_FIRST == capitalizeType) {
+ suggestion = StringUtils.capitalizeFirstCodePoint(
+ suggestedWordInfo.mWord, locale);
+ } else {
+ suggestion = suggestedWordInfo.mWord;
+ }
+ suggestions.add(suggestion);
+ }
+ StringUtils.removeDupes(suggestions);
+ // This returns a String[], while toArray() returns an Object[] which cannot be cast
+ // into a String[].
+ final List<String> gatheredSuggestionsList =
+ suggestions.subList(0, Math.min(suggestions.size(), suggestionsLimit));
+ final String[] gatheredSuggestions =
+ gatheredSuggestionsList.toArray(new String[gatheredSuggestionsList.size()]);
+
+ final int bestScore = suggestionResults.first().mScore;
+ final String bestSuggestion = suggestions.get(0);
+ final float normalizedScore = BinaryDictionaryUtils.calcNormalizedScore(
+ originalText, bestSuggestion, bestScore);
+ final boolean hasRecommendedSuggestions = (normalizedScore > recommendedThreshold);
+ return new Result(gatheredSuggestions, hasRecommendedSuggestions);
+ }
+
+ /*
+ * The spell checker acts on its own behalf. That is needed, in particular, to be able to
+ * access the dictionary files, which the provider restricts to the identity of Latin IME.
+ * Since it's called externally by the application, the spell checker is using the identity
+ * of the application by default unless we clearCallingIdentity.
+ * That's what the following method does.
+ */
+ @Override
+ public SuggestionsInfo onGetSuggestions(final TextInfo textInfo, final int suggestionsLimit) {
+ long ident = Binder.clearCallingIdentity();
+ try {
+ return onGetSuggestionsInternal(textInfo, suggestionsLimit);
+ } finally {
+ Binder.restoreCallingIdentity(ident);
+ }
+ }
+}
diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/SentenceLevelAdapter.java b/java/src/org/kelar/inputmethod/latin/spellcheck/SentenceLevelAdapter.java
new file mode 100644
index 000000000..4dbcd092e
--- /dev/null
+++ b/java/src/org/kelar/inputmethod/latin/spellcheck/SentenceLevelAdapter.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.kelar.inputmethod.latin.spellcheck;
+
+import android.annotation.TargetApi;
+import android.content.res.Resources;
+import android.os.Build;
+import android.view.textservice.SentenceSuggestionsInfo;
+import android.view.textservice.SuggestionsInfo;
+import android.view.textservice.TextInfo;
+
+import org.kelar.inputmethod.compat.TextInfoCompatUtils;
+import org.kelar.inputmethod.latin.common.Constants;
+import org.kelar.inputmethod.latin.settings.SpacingAndPunctuations;
+import org.kelar.inputmethod.latin.utils.RunInLocale;
+
+import java.util.ArrayList;
+import java.util.Locale;
+
+/**
+ * This code is mostly lifted directly from android.service.textservice.SpellCheckerService in
+ * the framework; maybe that should be protected instead, so that implementers don't have to
+ * rewrite everything for any small change.
+ */
+public class SentenceLevelAdapter {
+ private static class EmptySentenceSuggestionsInfosInitializationHolder {
+ public static final SentenceSuggestionsInfo[] EMPTY_SENTENCE_SUGGESTIONS_INFOS =
+ new SentenceSuggestionsInfo[]{};
+ }
+ private static final SuggestionsInfo EMPTY_SUGGESTIONS_INFO = new SuggestionsInfo(0, null);
+
+ public static SentenceSuggestionsInfo[] getEmptySentenceSuggestionsInfo() {
+ return EmptySentenceSuggestionsInfosInitializationHolder.EMPTY_SENTENCE_SUGGESTIONS_INFOS;
+ }
+
+ /**
+ * Container for split TextInfo parameters
+ */
+ public static class SentenceWordItem {
+ public final TextInfo mTextInfo;
+ public final int mStart;
+ public final int mLength;
+ public SentenceWordItem(TextInfo ti, int start, int end) {
+ mTextInfo = ti;
+ mStart = start;
+ mLength = end - start;
+ }
+ }
+
+ /**
+ * Container for originally queried TextInfo and parameters
+ */
+ public static class SentenceTextInfoParams {
+ final TextInfo mOriginalTextInfo;
+ final ArrayList<SentenceWordItem> mItems;
+ final int mSize;
+ public SentenceTextInfoParams(TextInfo ti, ArrayList<SentenceWordItem> items) {
+ mOriginalTextInfo = ti;
+ mItems = items;
+ mSize = items.size();
+ }
+ }
+
+ private static class WordIterator {
+ private final SpacingAndPunctuations mSpacingAndPunctuations;
+ public WordIterator(final Resources res, final Locale locale) {
+ final RunInLocale<SpacingAndPunctuations> job =
+ new RunInLocale<SpacingAndPunctuations>() {
+ @Override
+ protected SpacingAndPunctuations job(final Resources r) {
+ return new SpacingAndPunctuations(r);
+ }
+ };
+ mSpacingAndPunctuations = job.runInLocale(res, locale);
+ }
+
+ public int getEndOfWord(final CharSequence sequence, final int fromIndex) {
+ final int length = sequence.length();
+ int index = fromIndex < 0 ? 0 : Character.offsetByCodePoints(sequence, fromIndex, 1);
+ while (index < length) {
+ final int codePoint = Character.codePointAt(sequence, index);
+ if (mSpacingAndPunctuations.isWordSeparator(codePoint)) {
+ // If it's a period, we want to stop here only if it's followed by another
+ // word separator. In all other cases we stop here.
+ if (Constants.CODE_PERIOD == codePoint) {
+ final int indexOfNextCodePoint =
+ index + Character.charCount(Constants.CODE_PERIOD);
+ if (indexOfNextCodePoint < length
+ && mSpacingAndPunctuations.isWordSeparator(
+ Character.codePointAt(sequence, indexOfNextCodePoint))) {
+ return index;
+ }
+ } else {
+ return index;
+ }
+ }
+ index += Character.charCount(codePoint);
+ }
+ return index;
+ }
+
+ public int getBeginningOfNextWord(final CharSequence sequence, final int fromIndex) {
+ final int length = sequence.length();
+ if (fromIndex >= length) {
+ return -1;
+ }
+ int index = fromIndex < 0 ? 0 : Character.offsetByCodePoints(sequence, fromIndex, 1);
+ while (index < length) {
+ final int codePoint = Character.codePointAt(sequence, index);
+ if (!mSpacingAndPunctuations.isWordSeparator(codePoint)) {
+ return index;
+ }
+ index += Character.charCount(codePoint);
+ }
+ return -1;
+ }
+ }
+
+ private final WordIterator mWordIterator;
+ public SentenceLevelAdapter(final Resources res, final Locale locale) {
+ mWordIterator = new WordIterator(res, locale);
+ }
+
+ public SentenceTextInfoParams getSplitWords(TextInfo originalTextInfo) {
+ final WordIterator wordIterator = mWordIterator;
+ final CharSequence originalText =
+ TextInfoCompatUtils.getCharSequenceOrString(originalTextInfo);
+ final int cookie = originalTextInfo.getCookie();
+ final int start = -1;
+ final int end = originalText.length();
+ final ArrayList<SentenceWordItem> wordItems = new ArrayList<>();
+ int wordStart = wordIterator.getBeginningOfNextWord(originalText, start);
+ int wordEnd = wordIterator.getEndOfWord(originalText, wordStart);
+ while (wordStart <= end && wordEnd != -1 && wordStart != -1) {
+ if (wordEnd >= start && wordEnd > wordStart) {
+ final TextInfo ti = TextInfoCompatUtils.newInstance(originalText, wordStart,
+ wordEnd, cookie, originalText.subSequence(wordStart, wordEnd).hashCode());
+ wordItems.add(new SentenceWordItem(ti, wordStart, wordEnd));
+ }
+ wordStart = wordIterator.getBeginningOfNextWord(originalText, wordEnd);
+ if (wordStart == -1) {
+ break;
+ }
+ wordEnd = wordIterator.getEndOfWord(originalText, wordStart);
+ }
+ return new SentenceTextInfoParams(originalTextInfo, wordItems);
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ public static SentenceSuggestionsInfo reconstructSuggestions(
+ SentenceTextInfoParams originalTextInfoParams, SuggestionsInfo[] results) {
+ if (results == null || results.length == 0) {
+ return null;
+ }
+ if (originalTextInfoParams == null) {
+ return null;
+ }
+ final int originalCookie = originalTextInfoParams.mOriginalTextInfo.getCookie();
+ final int originalSequence =
+ originalTextInfoParams.mOriginalTextInfo.getSequence();
+
+ final int querySize = originalTextInfoParams.mSize;
+ final int[] offsets = new int[querySize];
+ final int[] lengths = new int[querySize];
+ final SuggestionsInfo[] reconstructedSuggestions = new SuggestionsInfo[querySize];
+ for (int i = 0; i < querySize; ++i) {
+ final SentenceWordItem item = originalTextInfoParams.mItems.get(i);
+ SuggestionsInfo result = null;
+ for (int j = 0; j < results.length; ++j) {
+ final SuggestionsInfo cur = results[j];
+ if (cur != null && cur.getSequence() == item.mTextInfo.getSequence()) {
+ result = cur;
+ result.setCookieAndSequence(originalCookie, originalSequence);
+ break;
+ }
+ }
+ offsets[i] = item.mStart;
+ lengths[i] = item.mLength;
+ reconstructedSuggestions[i] = result != null ? result : EMPTY_SUGGESTIONS_INFO;
+ }
+ return new SentenceSuggestionsInfo(reconstructedSuggestions, offsets, lengths);
+ }
+}
diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java b/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java
new file mode 100644
index 000000000..acbfa8666
--- /dev/null
+++ b/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.kelar.inputmethod.latin.spellcheck;
+
+import org.kelar.inputmethod.latin.permissions.PermissionsManager;
+import org.kelar.inputmethod.latin.utils.FragmentUtils;
+
+import android.annotation.TargetApi;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.preference.PreferenceActivity;
+import androidx.core.app.ActivityCompat;
+
+/**
+ * Spell checker preference screen.
+ */
+public final class SpellCheckerSettingsActivity extends PreferenceActivity
+ implements ActivityCompat.OnRequestPermissionsResultCallback {
+ private static final String DEFAULT_FRAGMENT = SpellCheckerSettingsFragment.class.getName();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public Intent getIntent() {
+ final Intent modIntent = new Intent(super.getIntent());
+ modIntent.putExtra(EXTRA_SHOW_FRAGMENT, DEFAULT_FRAGMENT);
+ modIntent.putExtra(EXTRA_NO_HEADERS, true);
+ return modIntent;
+ }
+
+ @TargetApi(Build.VERSION_CODES.KITKAT)
+ @Override
+ public boolean isValidFragment(String fragmentName) {
+ return FragmentUtils.isValidFragment(fragmentName);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, String[] permissions, int[] grantResults) {
+ PermissionsManager.get(this).onRequestPermissionsResult(
+ requestCode, permissions, grantResults);
+ }
+}
diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsFragment.java
new file mode 100644
index 000000000..e60173932
--- /dev/null
+++ b/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsFragment.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.kelar.inputmethod.latin.spellcheck;
+
+import android.Manifest;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.PreferenceScreen;
+import android.preference.SwitchPreference;
+import android.text.TextUtils;
+
+import org.kelar.inputmethod.latin.R;
+import org.kelar.inputmethod.latin.permissions.PermissionsManager;
+import org.kelar.inputmethod.latin.permissions.PermissionsUtil;
+import org.kelar.inputmethod.latin.settings.SubScreenFragment;
+import org.kelar.inputmethod.latin.settings.TwoStatePreferenceHelper;
+import org.kelar.inputmethod.latin.utils.ApplicationUtils;
+
+import static org.kelar.inputmethod.latin.permissions.PermissionsManager.get;
+
+/**
+ * Preference screen.
+ */
+public final class SpellCheckerSettingsFragment extends SubScreenFragment
+ implements SharedPreferences.OnSharedPreferenceChangeListener,
+ PermissionsManager.PermissionsResultCallback {
+
+ private SwitchPreference mLookupContactsPreference;
+
+ @Override
+ public void onActivityCreated(final Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ addPreferencesFromResource(R.xml.spell_checker_settings);
+ final PreferenceScreen preferenceScreen = getPreferenceScreen();
+ preferenceScreen.setTitle(ApplicationUtils.getActivityTitleResId(
+ getActivity(), SpellCheckerSettingsActivity.class));
+ TwoStatePreferenceHelper.replaceCheckBoxPreferencesBySwitchPreferences(preferenceScreen);
+
+ mLookupContactsPreference = (SwitchPreference) findPreference(
+ AndroidSpellCheckerService.PREF_USE_CONTACTS_KEY);
+ turnOffLookupContactsIfNoPermission();
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ if (!TextUtils.equals(key, AndroidSpellCheckerService.PREF_USE_CONTACTS_KEY)) {
+ return;
+ }
+
+ if (!sharedPreferences.getBoolean(key, false)) {
+ // don't care if the preference is turned off.
+ return;
+ }
+
+ // Check for permissions.
+ if (PermissionsUtil.checkAllPermissionsGranted(
+ getActivity() /* context */, Manifest.permission.READ_CONTACTS)) {
+ return; // all permissions granted, no need to request permissions.
+ }
+
+ get(getActivity() /* context */).requestPermissions(this /* PermissionsResultCallback */,
+ getActivity() /* activity */, Manifest.permission.READ_CONTACTS);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(boolean allGranted) {
+ turnOffLookupContactsIfNoPermission();
+ }
+
+ private void turnOffLookupContactsIfNoPermission() {
+ if (!PermissionsUtil.checkAllPermissionsGranted(
+ getActivity(), Manifest.permission.READ_CONTACTS)) {
+ mLookupContactsPreference.setChecked(false);
+ }
+ }
+}