From ac7ac4f90772ee407b03ea6d1423906b7ce09741 Mon Sep 17 00:00:00 2001 From: Helium314 Date: Mon, 26 Jun 2023 23:47:21 +0200 Subject: [PATCH] add multilingual typing --- README.md | 6 +- .../keyboard/MainKeyboardView.java | 21 ++ .../latin/DictionaryFacilitatorImpl.java | 280 +++++++++++++++--- .../SecondaryLocaleSettingsFragment.java | 196 ++++++++++++ .../inputmethod/latin/settings/Settings.java | 16 + .../latin/settings/SettingsValues.java | 3 +- .../inputmethod/latin/utils/ScriptUtils.java | 3 + app/src/main/res/values/strings.xml | 8 + .../main/res/xml/prefs_screen_advanced.xml | 6 + 9 files changed, 504 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SecondaryLocaleSettingsFragment.java diff --git a/README.md b/README.md index ba180e009..60206cf29 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ Plan / to do: * maybe: rename (package, app, icon), so it can be installed parallel to OpenBoard, and published on F-Droid * ~user-selectable dictionaries, https://github.com/openboard-team/openboard/pull/578~ * ~make additional dictionaries available for download (from OpenBoard PRs)~ -* multi-lingual typing, https://github.com/openboard-team/openboard/pull/593 +* ~multi-lingual typing, https://github.com/openboard-team/openboard/pull/586, https://github.com/openboard-team/openboard/pull/593~ + * maybe improve way of merging suggestions from both languages + * test whether it works reasonably well in non-latin scripts * ~suggestion fixes, https://github.com/openboard-team/openboard/pull/694, https://github.com/openboard-team/openboard/issues/795, https://github.com/openboard-team/openboard/issues/660~ * ~improve auto-space insertion, https://github.com/openboard-team/openboard/pull/576~ * emoji prediction/search, either https://github.com/openboard-team/openboard/pull/749 or use dictionaries @@ -19,6 +21,7 @@ Plan / to do: * delete suggestions, https://github.com/openboard-team/openboard/issues/106 * glide typing, https://github.com/openboard-team/openboard/issues/3 * license issues, maybe allow using an external library +* re-consider preferring lowercase word for typed history in some cases (DictionaryFacilitatorImpl.addWordToUserHistory) Changes: * Updated dependencies @@ -34,6 +37,7 @@ Changes: * Fix suggestions sometimes not being shown, https://github.com/openboard-team/openboard/pull/709 * Reduce amount of unwanted automatic space insertions, https://github.com/openboard-team/openboard/pull/576 * Exit shift-symbol view on space (same as normal symbol view) +* Add multi-lingual typing, slightly modified from https://github.com/openboard-team/openboard/pull/586, https://github.com/openboard-team/openboard/pull/593 ----- diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/MainKeyboardView.java b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/MainKeyboardView.java index b11f7fcdc..77320c4df 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/MainKeyboardView.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/MainKeyboardView.java @@ -55,10 +55,12 @@ import org.dslul.openboard.inputmethod.latin.SuggestedWords; import org.dslul.openboard.inputmethod.latin.common.Constants; import org.dslul.openboard.inputmethod.latin.common.CoordinateUtils; import org.dslul.openboard.inputmethod.latin.settings.DebugSettings; +import org.dslul.openboard.inputmethod.latin.settings.Settings; import org.dslul.openboard.inputmethod.latin.utils.DeviceProtectedUtils; import org.dslul.openboard.inputmethod.latin.utils.LanguageOnSpacebarUtils; import org.dslul.openboard.inputmethod.latin.utils.TypefaceUtils; +import java.util.Locale; import java.util.WeakHashMap; import javax.annotation.Nonnull; @@ -838,6 +840,25 @@ public final class MainKeyboardView extends KeyboardView implements DrawingProxy private String layoutLanguageOnSpacebar(final Paint paint, final RichInputMethodSubtype subtype, final int width) { // Choose appropriate language name to fit into the width. + + final Locale secondaryLocale = Settings.getInstance().getCurrent().mSecondaryLocale; + if (secondaryLocale != null + // avoid showing same language twice + && !secondaryLocale.getLanguage().equals(subtype.getLocale().getLanguage()) + ) { + final Locale displayLocale = getResources().getConfiguration().locale; + final String full = subtype.getMiddleDisplayName() + " - " + + secondaryLocale.getDisplayLanguage(displayLocale); + if (fitsTextIntoWidth(width, full, paint)) { + return full; + } + final String middle = subtype.getLocale().getLanguage().toUpperCase(displayLocale) + + " - " + secondaryLocale.getLanguage().toUpperCase(displayLocale); + if (fitsTextIntoWidth(width, middle, paint)) { + return middle; + } + } + if (mLanguageOnSpacebarFormatType == LanguageOnSpacebarUtils.FORMAT_TYPE_FULL_LOCALE) { final String fullText = subtype.getFullDisplayName(); if (fitsTextIntoWidth(width, fullText, paint)) { diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/DictionaryFacilitatorImpl.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/DictionaryFacilitatorImpl.java index eeb9f57f2..06a880fba 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/DictionaryFacilitatorImpl.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/DictionaryFacilitatorImpl.java @@ -31,8 +31,10 @@ import org.dslul.openboard.inputmethod.latin.common.Constants; import org.dslul.openboard.inputmethod.latin.common.StringUtils; import org.dslul.openboard.inputmethod.latin.permissions.PermissionsUtil; import org.dslul.openboard.inputmethod.latin.personalization.UserHistoryDictionary; +import org.dslul.openboard.inputmethod.latin.settings.Settings; import org.dslul.openboard.inputmethod.latin.settings.SettingsValuesForSuggestion; import org.dslul.openboard.inputmethod.latin.utils.ExecutorUtils; +import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils; import org.dslul.openboard.inputmethod.latin.utils.SuggestionResults; import java.io.File; @@ -69,6 +71,7 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { private static final int CAPITALIZED_FORM_MAX_PROBABILITY_FOR_INSERT = 140; private DictionaryGroup mDictionaryGroup = new DictionaryGroup(); + private DictionaryGroup mSecondaryDictionaryGroup = new DictionaryGroup(); private volatile CountDownLatch mLatchForWaitingLoadingMainDictionaries = new CountDownLatch(0); // To synchronize assigning mDictionaryGroup to ensure closing dictionaries. private final Object mLock = new Object(); @@ -86,6 +89,9 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { private static final Class[] DICT_FACTORY_METHOD_ARG_TYPES = new Class[] { Context.class, Locale.class, File.class, String.class, String.class }; + // these caches are never even set, as the corresponding functions are not called... + // and even if they were set, one is only written, but never read, and the other one + // is only read and thus empty and useless private LruCache mValidSpellingWordReadCache; private LruCache mValidSpellingWordWriteCache; @@ -104,6 +110,11 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { return locale != null && locale.equals(mDictionaryGroup.mLocale); } + private boolean hasLocale(final Locale locale) { + return locale != null && (locale.equals(mDictionaryGroup.mLocale) || + (mSecondaryDictionaryGroup != null && locale.equals(mSecondaryDictionaryGroup.mLocale))); + } + /** * Returns whether this facilitator is exactly for this account. * @@ -124,6 +135,9 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { public static final float WEIGHT_FOR_GESTURING_IN_NOT_MOST_PROBABLE_LANGUAGE = 0.95f; public static final float WEIGHT_FOR_TYPING_IN_NOT_MOST_PROBABLE_LANGUAGE = 0.6f; + private static final int MAX_CONFIDENCE = 2; + private static final int MIN_CONFIDENCE = 0; + /** * The locale associated with the dictionary group. */ @@ -137,8 +151,34 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { @Nullable private Dictionary mMainDict; // Confidence that the most probable language is actually the language the user is // typing in. For now, this is simply the number of times a word from this language - // has been committed in a row. - private int mConfidence = 0; + // has been committed in a row, with an exception when typing a single word not contained + // in this language. + private int mConfidence = 1; + + // allow to go above max confidence, for better determination of currently preferred language + // when decreasing confidence or getting weight factor, limit to maximum + public void increaseConfidence() { + mConfidence += 1; + if (mConfidence <= MAX_CONFIDENCE) + updateWeights(); + } + + // If confidence is above max, drop to max confidence. This does not change weights and + // allows conveniently typing single words from the other language without affecting suggestions + public void decreaseConfidence() { + if (mConfidence > MAX_CONFIDENCE) + mConfidence = MAX_CONFIDENCE; + else if (mConfidence > MIN_CONFIDENCE) { + mConfidence -= 1; + updateWeights(); + } + } + + // TODO: might need some more tuning, maybe more confidence steps + private void updateWeights() { + mWeightForTypingInLocale = 1f - 0.15f * (MAX_CONFIDENCE - mConfidence); + mWeightForGesturingInLocale = 1f - 0.05f * (MAX_CONFIDENCE - mConfidence); + } public float mWeightForTypingInLocale = WEIGHT_FOR_MOST_PROBABLE_LANGUAGE; public float mWeightForGesturingInLocale = WEIGHT_FOR_MOST_PROBABLE_LANGUAGE; @@ -229,6 +269,11 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { Dictionary dict = mDictionaryGroup.getDict(dictType); if (dict != null) dict.onFinishInput(); } + if (mSecondaryDictionaryGroup != null) + for (final String dictType : ALL_DICTIONARY_TYPES) { + Dictionary dict = mSecondaryDictionaryGroup.getDict(dictType); + if (dict != null) dict.onFinishInput(); + } } @Override @@ -276,10 +321,15 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { @Nullable static DictionaryGroup findDictionaryGroupWithLocale(final DictionaryGroup dictionaryGroup, final Locale locale) { + if (dictionaryGroup == null) return null; return locale.equals(dictionaryGroup.mLocale) ? dictionaryGroup : null; } @Override + // TODO: what if secondary locale changes, but main remains same? + // current reset doesn't consider this (not here, and not in other places where locales + // are checked against current locale) + // but that actually shouldn't happen anyway... public void resetDictionaries( final Context context, final Locale newLocale, @@ -353,13 +403,56 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { DictionaryGroup newDictionaryGroup = new DictionaryGroup(newLocale, mainDict, account, subDicts); + // create / load secondary dictionary + final Locale secondaryLocale = Settings.getInstance().getCurrent().mSecondaryLocale; + final DictionaryGroup newSecondaryDictionaryGroup; + final Map secondarySubDicts = new HashMap<>(); + + if (secondaryLocale != null && + ScriptUtils.getScriptFromSpellCheckerLocale(secondaryLocale) == ScriptUtils.getScriptFromSpellCheckerLocale(newLocale)) { + final ArrayList dictTypesToCleanUp = new ArrayList<>(); + for (final String dictType : ALL_DICTIONARY_TYPES) { + if (mSecondaryDictionaryGroup != null && mSecondaryDictionaryGroup.hasDict(dictType, account)) { + dictTypesToCleanUp.add(dictType); + } + } + for (final String subDictType : subDictTypesToUse) { + final ExpandableBinaryDictionary subDict = + getSubDict(subDictType, context, secondaryLocale, null, dictNamePrefix, account); + secondarySubDicts.put(subDictType, subDict); + dictTypesToCleanUp.remove(subDictType); + } + final Dictionary secondaryMainDict; + if (forceReloadMainDictionary || findDictionaryGroupWithLocale(mSecondaryDictionaryGroup, secondaryLocale) == null + || !mSecondaryDictionaryGroup.hasDict(Dictionary.TYPE_MAIN, account)) { + secondaryMainDict = null; + } else { + if (mSecondaryDictionaryGroup == null) + secondaryMainDict = null; + else + secondaryMainDict = mSecondaryDictionaryGroup.getDict(Dictionary.TYPE_MAIN); + dictTypesToCleanUp.remove(Dictionary.TYPE_MAIN); + } + newSecondaryDictionaryGroup = new DictionaryGroup(secondaryLocale, secondaryMainDict, account, secondarySubDicts); + + // do the cleanup like for main dict: look like this is for removing dictionaries + // after user changed enabled types (e.g. disable personalized suggestions) + existingDictionariesToCleanup.put(secondaryLocale, dictTypesToCleanUp); + } else { + newSecondaryDictionaryGroup = null; + } + // Replace Dictionaries. final DictionaryGroup oldDictionaryGroup; + final DictionaryGroup oldSecondaryDictionaryGroup; synchronized (mLock) { oldDictionaryGroup = mDictionaryGroup; mDictionaryGroup = newDictionaryGroup; + oldSecondaryDictionaryGroup = mSecondaryDictionaryGroup; + mSecondaryDictionaryGroup = newSecondaryDictionaryGroup; if (hasAtLeastOneUninitializedMainDictionary()) { - asyncReloadUninitializedMainDictionaries(context, newLocale, listener); + asyncReloadUninitializedMainDictionaries(context, newLocale, + mSecondaryDictionaryGroup == null ? null : secondaryLocale, listener); } } if (listener != null) { @@ -370,8 +463,13 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { for (final Locale localeToCleanUp : existingDictionariesToCleanup.keySet()) { final ArrayList dictTypesToCleanUp = existingDictionariesToCleanup.get(localeToCleanUp); - final DictionaryGroup dictionarySetToCleanup = + DictionaryGroup dictionarySetToCleanup = findDictionaryGroupWithLocale(oldDictionaryGroup, localeToCleanUp); + if (dictionarySetToCleanup == null) + dictionarySetToCleanup = + findDictionaryGroupWithLocale(oldSecondaryDictionaryGroup, localeToCleanUp); + if (dictionarySetToCleanup == null) + continue; for (final String dictType : dictTypesToCleanUp) { dictionarySetToCleanup.closeDict(dictType); } @@ -383,20 +481,20 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { } private void asyncReloadUninitializedMainDictionaries(final Context context, - final Locale locale, final DictionaryInitializationListener listener) { + final Locale locale, final Locale secondaryLocale, final DictionaryInitializationListener listener) { final CountDownLatch latchForWaitingLoadingMainDictionary = new CountDownLatch(1); mLatchForWaitingLoadingMainDictionaries = latchForWaitingLoadingMainDictionary; ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute(new Runnable() { @Override public void run() { doReloadUninitializedMainDictionaries( - context, locale, listener, latchForWaitingLoadingMainDictionary); + context, locale, secondaryLocale, listener, latchForWaitingLoadingMainDictionary); } }); } void doReloadUninitializedMainDictionaries(final Context context, final Locale locale, - final DictionaryInitializationListener listener, + final Locale secondaryLocale, final DictionaryInitializationListener listener, final CountDownLatch latchForWaitingLoadingMainDictionary) { final DictionaryGroup dictionaryGroup = findDictionaryGroupWithLocale(mDictionaryGroup, locale); @@ -407,6 +505,18 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { } final Dictionary mainDict = DictionaryFactory.createMainDictionaryFromManager(context, locale); + + final DictionaryGroup secondaryDictionaryGroup; + if (secondaryLocale == null) + secondaryDictionaryGroup = null; + else + secondaryDictionaryGroup = findDictionaryGroupWithLocale(mSecondaryDictionaryGroup, secondaryLocale); + final Dictionary secondaryMainDict; + if (secondaryLocale == null) + secondaryMainDict = null; + else + secondaryMainDict = DictionaryFactory.createMainDictionaryFromManager(context, secondaryLocale); + synchronized (mLock) { if (locale.equals(dictionaryGroup.mLocale)) { dictionaryGroup.setMainDict(mainDict); @@ -414,6 +524,10 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { // Dictionary facilitator has been reset for another locale. mainDict.close(); } + if (secondaryDictionaryGroup != null && secondaryLocale.equals(secondaryDictionaryGroup.mLocale)) + secondaryDictionaryGroup.setMainDict(secondaryMainDict); + else if (secondaryMainDict != null) + secondaryMainDict.close(); } if (listener != null) { listener.onUpdateMainDictionaryAvailability(hasAtLeastOneInitializedMainDictionary()); @@ -453,13 +567,19 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { } public void closeDictionaries() { - final DictionaryGroup dictionaryGroupToClose; + final DictionaryGroup mainDictionaryGroupToClose; + final DictionaryGroup secondaryDictionaryGroupToClose; synchronized (mLock) { - dictionaryGroupToClose = mDictionaryGroup; + mainDictionaryGroupToClose = mDictionaryGroup; + secondaryDictionaryGroupToClose = mSecondaryDictionaryGroup; mDictionaryGroup = new DictionaryGroup(); + if (mSecondaryDictionaryGroup != null) + mSecondaryDictionaryGroup = new DictionaryGroup(); } for (final String dictType : ALL_DICTIONARY_TYPES) { - dictionaryGroupToClose.closeDict(dictType); + mainDictionaryGroupToClose.closeDict(dictType); + if (secondaryDictionaryGroupToClose != null) + secondaryDictionaryGroupToClose.closeDict(dictType); } } @@ -477,6 +597,11 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { public boolean hasAtLeastOneUninitializedMainDictionary() { final Dictionary mainDict = mDictionaryGroup.getDict(Dictionary.TYPE_MAIN); + if (mSecondaryDictionaryGroup != null) { + final Dictionary secondaryDict = mSecondaryDictionaryGroup.getDict(Dictionary.TYPE_MAIN); + if (secondaryDict == null || !secondaryDict.isInitialized()) + return true; + } return mainDict == null || !mainDict.isInitialized(); } @@ -502,11 +627,30 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { putWordIntoValidSpellingWordCache("addToUserHistory", suggestion); final String[] words = suggestion.split(Constants.WORD_SEPARATOR); + + // increase / decrease confidence if we have a secondary dictionary group + if (mSecondaryDictionaryGroup != null && words.length == 1) { + // if suggestion was auto-capitalized, check against both the suggestion and the de-capitalized suggestion + final String decapitalizedSuggestion; + if (wasAutoCapitalized) + decapitalizedSuggestion = suggestion.substring(0, 1).toLowerCase() + suggestion.substring(1); + else + decapitalizedSuggestion = suggestion; + if ((wasAutoCapitalized && isValidWord(decapitalizedSuggestion, ALL_DICTIONARY_TYPES, mDictionaryGroup)) + || isValidWord(suggestion, ALL_DICTIONARY_TYPES, mDictionaryGroup)) + mDictionaryGroup.increaseConfidence(); + else mDictionaryGroup.decreaseConfidence(); + if ((wasAutoCapitalized && isValidWord(decapitalizedSuggestion, ALL_DICTIONARY_TYPES, mSecondaryDictionaryGroup)) + || isValidWord(suggestion, ALL_DICTIONARY_TYPES, mSecondaryDictionaryGroup)) + mSecondaryDictionaryGroup.increaseConfidence(); + else mSecondaryDictionaryGroup.decreaseConfidence(); + } NgramContext ngramContextForCurrentWord = ngramContext; for (int i = 0; i < words.length; i++) { final String currentWord = words[i]; final boolean wasCurrentWordAutoCapitalized = (i == 0) && wasAutoCapitalized; - addWordToUserHistory(mDictionaryGroup, ngramContextForCurrentWord, currentWord, + // add to history for preferred dictionary group, to avoid mixing languages in history + addWordToUserHistory(getCurrentlyPreferredDictionaryGroup(), ngramContextForCurrentWord, currentWord, wasCurrentWordAutoCapitalized, (int) timeStampInSeconds, blockPotentiallyOffensive); ngramContextForCurrentWord = @@ -542,17 +686,19 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { final int timeStampInSeconds, final boolean blockPotentiallyOffensive) { final ExpandableBinaryDictionary userHistoryDictionary = dictionaryGroup.getSubDict(Dictionary.TYPE_USER_HISTORY); - if (userHistoryDictionary == null || !isForLocale(userHistoryDictionary.mLocale)) { + if (userHistoryDictionary == null || !hasLocale(userHistoryDictionary.mLocale)) { return; } - final int maxFreq = getFrequency(word); + final int maxFreq = getFrequency(word, dictionaryGroup); if (maxFreq == 0 && blockPotentiallyOffensive) { return; } - final String lowerCasedWord = word.toLowerCase(dictionaryGroup.mLocale); final String secondWord; if (wasAutoCapitalized) { - if (isValidSuggestionWord(word) && !isValidSuggestionWord(lowerCasedWord)) { + // used word with lower-case first letter instead of all lower-case, as auto-capitalize + // does not affect the other letters + final String decapitalizedWord = word.substring(0, 1).toLowerCase(dictionaryGroup.mLocale) + word.substring(1); + if (isValidWord(word, ALL_DICTIONARY_TYPES, dictionaryGroup) && !isValidWord(decapitalizedWord, ALL_DICTIONARY_TYPES, dictionaryGroup)) { // If the word was auto-capitalized and exists only as a capitalized word in the // dictionary, then we must not downcase it before registering it. For example, // the name of the contacts in start-of-sentence position would come here with the @@ -560,15 +706,16 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { // of that contact's name which would end up popping in suggestions. secondWord = word; } else { - // If however the word is not in the dictionary, or exists as a lower-case word + // If however the word is not in the dictionary, or exists as a de-capitalized word // only, then we consider that was a lower-case word that had been auto-capitalized. - secondWord = lowerCasedWord; + secondWord = decapitalizedWord; } } else { // HACK: We'd like to avoid adding the capitalized form of common words to the User // History dictionary in order to avoid suggesting them until the dictionary // consolidation is done. // TODO: Remove this hack when ready. + final String lowerCasedWord = word.toLowerCase(dictionaryGroup.mLocale); final int lowerCaseFreqInMainDict = dictionaryGroup.hasDict(Dictionary.TYPE_MAIN, null /* account */) ? dictionaryGroup.getDict(Dictionary.TYPE_MAIN).getFrequency(lowerCasedWord) : @@ -588,8 +735,18 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { isValid, timeStampInSeconds); } + /** returns the dictionaryGroup with most confidence, main group when tied */ + private DictionaryGroup getCurrentlyPreferredDictionaryGroup() { + final DictionaryGroup dictGroup; + if (mSecondaryDictionaryGroup == null || mSecondaryDictionaryGroup.mConfidence <= mDictionaryGroup.mConfidence) + dictGroup = mDictionaryGroup; + else + dictGroup = mSecondaryDictionaryGroup; + return dictGroup; + } + private void removeWord(final String dictName, final String word) { - final ExpandableBinaryDictionary dictionary = mDictionaryGroup.getSubDict(dictName); + final ExpandableBinaryDictionary dictionary = getCurrentlyPreferredDictionaryGroup().getSubDict(dictName); if (dictionary != null) { dictionary.removeUnigramEntryDynamically(word); } @@ -621,25 +778,70 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { false /* firstSuggestionExceedsConfidenceThreshold */); final float[] weightOfLangModelVsSpatialModel = new float[] { Dictionary.NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL }; + + // start getting suggestions for secondary locale first, but in separate thread + final ArrayList dictionarySuggestionsSecondary = new ArrayList<>(); + final CountDownLatch waitForSecondaryDictionary = new CountDownLatch(1); + if (mSecondaryDictionaryGroup != null) { + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute(new Runnable() { + @Override + public void run() { + dictionarySuggestionsSecondary.addAll(getSuggestions(composedData, + ngramContext, settingsValuesForSuggestion, sessionId, proximityInfoHandle, + weightOfLangModelVsSpatialModel, mSecondaryDictionaryGroup)); + waitForSecondaryDictionary.countDown(); + } + }); + } + + // get main locale suggestions + final ArrayList dictionarySuggestions = getSuggestions(composedData, + ngramContext, settingsValuesForSuggestion, sessionId, proximityInfoHandle, + weightOfLangModelVsSpatialModel, mDictionaryGroup); + suggestionResults.addAll(dictionarySuggestions); + if (null != suggestionResults.mRawSuggestions) { + suggestionResults.mRawSuggestions.addAll(dictionarySuggestions); + } + + // wait for secondary locale suggestions + if (mSecondaryDictionaryGroup != null) { + try { waitForSecondaryDictionary.await(); } + catch (InterruptedException e) { + Log.w(TAG, "Interrupted while trying to get secondary locale suggestions", e); + } + suggestionResults.addAll(dictionarySuggestionsSecondary); + if (null != suggestionResults.mRawSuggestions) { + suggestionResults.mRawSuggestions.addAll(dictionarySuggestionsSecondary); + } + } + + return suggestionResults; + } + + private ArrayList getSuggestions(ComposedData composedData, + NgramContext ngramContext, SettingsValuesForSuggestion settingsValuesForSuggestion, + int sessionId, long proximityInfoHandle, float[] weightOfLangModelVsSpatialModel, + DictionaryGroup dictGroup) { + final ArrayList suggestions = new ArrayList<>(); for (final String dictType : ALL_DICTIONARY_TYPES) { - final Dictionary dictionary = mDictionaryGroup.getDict(dictType); + final Dictionary dictionary = dictGroup.getDict(dictType); if (null == dictionary) continue; final float weightForLocale = composedData.mIsBatchMode - ? mDictionaryGroup.mWeightForGesturingInLocale - : mDictionaryGroup.mWeightForTypingInLocale; + ? dictGroup.mWeightForGesturingInLocale + : dictGroup.mWeightForTypingInLocale; final ArrayList dictionarySuggestions = dictionary.getSuggestions(composedData, ngramContext, proximityInfoHandle, settingsValuesForSuggestion, sessionId, weightForLocale, weightOfLangModelVsSpatialModel); if (null == dictionarySuggestions) continue; - suggestionResults.addAll(dictionarySuggestions); - if (null != suggestionResults.mRawSuggestions) { - suggestionResults.mRawSuggestions.addAll(dictionarySuggestions); - } + suggestions.addAll(dictionarySuggestions); } - return suggestionResults; + return suggestions; } + // Spell checker is using this, and has its own instance of DictionaryFacilitatorImpl, + // meaning that it always has default mConfidence. So we cannot choose to only check preferred + // locale, and instead simply return true if word is in any of the available dictionaries public boolean isValidSpellingWord(final String word) { if (mValidSpellingWordReadCache != null) { final Boolean cachedValue = mValidSpellingWordReadCache.get(word); @@ -648,22 +850,23 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { } } - return isValidWord(word, ALL_DICTIONARY_TYPES); + return isValidWord(word, ALL_DICTIONARY_TYPES, mDictionaryGroup) || + (mSecondaryDictionaryGroup != null && isValidWord(word, ALL_DICTIONARY_TYPES, mSecondaryDictionaryGroup)); } public boolean isValidSuggestionWord(final String word) { - return isValidWord(word, ALL_DICTIONARY_TYPES); + return isValidWord(word, ALL_DICTIONARY_TYPES, mDictionaryGroup); } - private boolean isValidWord(final String word, final String[] dictionariesToCheck) { + private boolean isValidWord(final String word, final String[] dictionariesToCheck, final DictionaryGroup dictionaryGroup) { if (TextUtils.isEmpty(word)) { return false; } - if (mDictionaryGroup.mLocale == null) { + if (dictionaryGroup.mLocale == null) { return false; } for (final String dictType : dictionariesToCheck) { - final Dictionary dictionary = mDictionaryGroup.getDict(dictType); + final Dictionary dictionary = dictionaryGroup.getDict(dictType); // Ideally the passed map would come out of a {@link java.util.concurrent.Future} and // would be immutable once it's finished initializing, but concretely a null test is // probably good enough for the time being. @@ -675,13 +878,18 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { return false; } - private int getFrequency(final String word) { + // called from addWordToUserHistory with a specified dictionary, so provide this dictionary + private int getFrequency(final String word, DictionaryGroup dictGroup) { if (TextUtils.isEmpty(word)) { return Dictionary.NOT_A_PROBABILITY; } int maxFreq = Dictionary.NOT_A_PROBABILITY; + // ExpandableBinaryDictionary (means: all except main) always return NOT_A_PROBABILITY + // because it doesn't override getFrequency() + // So why is it checked anyway? + // Is this a bug, or intended by AOSP devs? for (final String dictType : ALL_DICTIONARY_TYPES) { - final Dictionary dictionary = mDictionaryGroup.getDict(dictType); + final Dictionary dictionary = dictGroup.getDict(dictType); if (dictionary == null) continue; final int tempFreq = dictionary.getFrequency(word); if (tempFreq >= maxFreq) { @@ -697,6 +905,12 @@ public class DictionaryFacilitatorImpl implements DictionaryFacilitator { return false; } dictionary.clear(); + // called when not using personalized dictionaries, so should also reset secondary user history + if (mSecondaryDictionaryGroup != null) { + final ExpandableBinaryDictionary secondaryDictionary = mSecondaryDictionaryGroup.getSubDict(dictName); + if (secondaryDictionary != null) + secondaryDictionary.clear(); + } return true; } diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SecondaryLocaleSettingsFragment.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SecondaryLocaleSettingsFragment.java new file mode 100644 index 000000000..ee2371148 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SecondaryLocaleSettingsFragment.java @@ -0,0 +1,196 @@ +/* + * 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.dslul.openboard.inputmethod.latin.settings; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.preference.Preference; +import android.view.inputmethod.InputMethodSubtype; + +import org.dslul.openboard.inputmethod.dictionarypack.DictionaryPackConstants; +import org.dslul.openboard.inputmethod.latin.BinaryDictionaryGetter; +import org.dslul.openboard.inputmethod.latin.BuildConfig; +import org.dslul.openboard.inputmethod.latin.R; +import org.dslul.openboard.inputmethod.latin.RichInputMethodManager; +import org.dslul.openboard.inputmethod.latin.common.LocaleUtils; +import org.dslul.openboard.inputmethod.latin.utils.DialogUtils; +import org.dslul.openboard.inputmethod.latin.utils.DictionaryInfoUtils; +import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public final class SecondaryLocaleSettingsFragment extends SubScreenFragment { + private RichInputMethodManager mRichImm; + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + RichInputMethodManager.init(getActivity()); + mRichImm = RichInputMethodManager.getInstance(); + addPreferencesFromResource(R.xml.additional_subtype_settings); + resetKeyboardLocales(); + } + + private void resetKeyboardLocales() { + mRichImm.refreshSubtypeCaches(); + getPreferenceScreen().removeAll(); + final Context context = getActivity(); + List subtypes = mRichImm.getMyEnabledInputMethodSubtypeList(true); + + for (InputMethodSubtype subtype : subtypes) { + final Locale secondaryLocale = Settings.getSecondaryLocale(getSharedPreferences(), subtype.getLocale()); + final Preference pref = new Preference(context); + pref.setTitle(subtype.getDisplayName(context, BuildConfig.APPLICATION_ID, context.getApplicationInfo())); + if (secondaryLocale != null) + pref.setSummary(secondaryLocale.getDisplayLanguage(getResources().getConfiguration().locale)); + + pref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + showSecondaryLocaleDialog(subtype.getLocale().toLowerCase(Locale.ENGLISH), subtype.isAsciiCapable()); + return true; + } + }); + getPreferenceScreen().addPreference(pref); + } + + } + + private void showSecondaryLocaleDialog(String mainLocale, boolean asciiCapable) { + final List locales = new ArrayList<>(getAvailableDictionaryLocales(mainLocale, asciiCapable)); + Collections.sort(locales); + + // we don't want to offer mainLocale as a choice, same goes for the language (e.g. en for en_GB) + locales.remove(mainLocale); + if (mainLocale.contains("_")) { + final String mainLanguage = LocaleUtils.constructLocaleFromString(mainLocale).getLanguage(); + locales.remove(mainLanguage); + } + + final AlertDialog.Builder builder = new AlertDialog.Builder( + DialogUtils.getPlatformDialogThemeContext(getActivity())) + .setTitle(R.string.language_selection_title) + .setPositiveButton(android.R.string.cancel, null); + + if (locales.isEmpty()) { + builder.setMessage(R.string.no_secondary_locales) + .show(); + return; + } + + // insert "no secondary language" option on top + locales.add(0, getResources().getString(R.string.secondary_locale_none)); + + final Locale displayLocale = getResources().getConfiguration().locale; + final CharSequence[] titles = locales.toArray(new CharSequence[0]); + for (int i = 1; i < titles.length ; i++) { + final Locale loc = LocaleUtils.constructLocaleFromString(titles[i].toString()); + titles[i] = loc.getDisplayName(displayLocale); + } + + Locale currentSecondaryLocale = Settings.getSecondaryLocale(getSharedPreferences(), mainLocale); + int checkedItem; + if (currentSecondaryLocale == null) + checkedItem = 0; + else + checkedItem = locales.indexOf(currentSecondaryLocale.toString()); + + builder.setSingleChoiceItems(titles, checkedItem, (dialogInterface, i) -> { + String locale = locales.get(i); + if (i == 0) + locale = ""; + final Set encodedLocales = new HashSet<>(); + boolean updated = false; + for (String encodedLocale : getSharedPreferences().getStringSet(Settings.PREF_SECONDARY_LOCALES, new HashSet<>())) { + String[] locs = encodedLocale.split("§"); + if (locs.length == 2 && locs[0].equals(mainLocale)) { + if (!locale.isEmpty()) + encodedLocales.add(mainLocale + "§" + locale); + updated = true; + } else { + encodedLocales.add(encodedLocale); + } + } + if (!updated) + encodedLocales.add(mainLocale + "§" + locale); + getSharedPreferences().edit().putStringSet(Settings.PREF_SECONDARY_LOCALES, encodedLocales).apply(); + final Intent newDictBroadcast = new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION); + getActivity().sendBroadcast(newDictBroadcast); + resetKeyboardLocales(); + dialogInterface.dismiss(); + }); + + builder.show(); + } + + // get locales with same script as main locale, but different language + private Set getAvailableDictionaryLocales(String mainLocale, boolean asciiCapable) { + final Locale mainL = LocaleUtils.constructLocaleFromString(mainLocale); + final Set locales = new HashSet<>(); + final int mainScript; + if (asciiCapable) + mainScript = ScriptUtils.SCRIPT_LATIN; + else + mainScript = ScriptUtils.getScriptFromSpellCheckerLocale(mainL); + // ScriptUtils.getScriptFromSpellCheckerLocale may return latin when it should not + // e.g. for persian or chinese + // workaround: don't allow secondary locales for these locales + if (!asciiCapable && mainScript == ScriptUtils.SCRIPT_LATIN) + return locales; + + // get cached dictionaries: extracted or user-added dictionaries + final File[] cachedDirectoryList = DictionaryInfoUtils.getCachedDirectoryList(getActivity()); + if (cachedDirectoryList != null) { + for (File directory : cachedDirectoryList) { + if (!directory.isDirectory()) continue; + final String dirLocale = + DictionaryInfoUtils.getWordListIdFromFileName(directory.getName()); + if (dirLocale.equals(mainLocale)) continue; + final Locale locale = LocaleUtils.constructLocaleFromString(dirLocale); + if (locale.getLanguage().equals(mainL.getLanguage())) continue; + int localeScript = ScriptUtils.getScriptFromSpellCheckerLocale(locale); + if (localeScript != mainScript) continue; + locales.add(locale.toString()); + } + } + // get assets dictionaries + final String[] assetsDictionaryList = BinaryDictionaryGetter.getAssetsDictionaryList(getActivity()); + if (assetsDictionaryList != null) { + for (String dictionary : assetsDictionaryList) { + final String dictLocale = + BinaryDictionaryGetter.extractLocaleFromAssetsDictionaryFile(dictionary); + if (dictLocale == null) continue; + if (dictLocale.equals(mainLocale)) continue; + final Locale locale = LocaleUtils.constructLocaleFromString(dictLocale); + if (locale.getLanguage().equals(mainL.getLanguage())) continue; + int localeScript = ScriptUtils.getScriptFromSpellCheckerLocale(locale); + if (localeScript != mainScript) continue; + locales.add(locale.toString()); + } + } + return locales; + } + +} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/Settings.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/Settings.java index 159b3dee2..917fac182 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/Settings.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/Settings.java @@ -29,15 +29,18 @@ import android.view.Gravity; import org.dslul.openboard.inputmethod.latin.AudioAndHapticFeedbackManager; import org.dslul.openboard.inputmethod.latin.InputAttributes; import org.dslul.openboard.inputmethod.latin.R; +import org.dslul.openboard.inputmethod.latin.common.LocaleUtils; import org.dslul.openboard.inputmethod.latin.common.StringUtils; import org.dslul.openboard.inputmethod.latin.utils.AdditionalSubtypeUtils; import org.dslul.openboard.inputmethod.latin.utils.DeviceProtectedUtils; import org.dslul.openboard.inputmethod.latin.utils.JniUtils; import org.dslul.openboard.inputmethod.latin.utils.ResourceUtils; import org.dslul.openboard.inputmethod.latin.utils.RunInLocale; +import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils; import org.dslul.openboard.inputmethod.latin.utils.StatsUtils; import java.util.Collections; +import java.util.HashSet; import java.util.Locale; import java.util.Set; import java.util.concurrent.locks.ReentrantLock; @@ -126,6 +129,8 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_ENABLE_CLIPBOARD_HISTORY = "pref_enable_clipboard_history"; public static final String PREF_CLIPBOARD_HISTORY_RETENTION_TIME = "pref_clipboard_history_retention_time"; + public static final String PREF_SECONDARY_LOCALES = "pref_secondary_locales"; + // This preference key is deprecated. Use {@link #PREF_SHOW_LANGUAGE_SWITCH_KEY} instead. // This is being used only for the backward compatibility. private static final String PREF_SUPPRESS_LANGUAGE_SWITCH_KEY = @@ -517,4 +522,15 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang final SharedPreferences prefs, final int defValue) { return prefs.getInt(PREF_LAST_SHOWN_EMOJI_CATEGORY_PAGE_ID, defValue); } + + public static Locale getSecondaryLocale(final SharedPreferences prefs, final String mainLocaleString) { + final Set encodedLocales = prefs.getStringSet(PREF_SECONDARY_LOCALES, new HashSet<>()); + for (String loc : encodedLocales) { + String[] locales = loc.split("§"); + if (locales.length == 2 && locales[0].equals(mainLocaleString.toLowerCase(Locale.ENGLISH))) + return LocaleUtils.constructLocaleFromString(locales[1]); + } + return null; + } + } diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SettingsValues.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SettingsValues.java index e4986f7df..a07aefba0 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SettingsValues.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SettingsValues.java @@ -28,7 +28,6 @@ import org.dslul.openboard.inputmethod.compat.AppWorkaroundsUtils; import org.dslul.openboard.inputmethod.latin.InputAttributes; import org.dslul.openboard.inputmethod.latin.R; import org.dslul.openboard.inputmethod.latin.RichInputMethodManager; -import org.dslul.openboard.inputmethod.latin.common.StringUtils; import org.dslul.openboard.inputmethod.latin.utils.AsyncResultHolder; import org.dslul.openboard.inputmethod.latin.utils.ResourceUtils; import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils; @@ -86,6 +85,7 @@ public class SettingsValues { public final long mClipboardHistoryRetentionTime; public final boolean mOneHandedModeEnabled; public final int mOneHandedModeGravity; + public final Locale mSecondaryLocale; // Use bigrams to predict the next word when there is no input for it yet public final boolean mBigramPredictionEnabled; public final boolean mGestureInputEnabled; @@ -242,6 +242,7 @@ public class SettingsValues { mClipboardHistoryRetentionTime = Settings.readClipboardHistoryRetentionTime(prefs, res); mOneHandedModeEnabled = Settings.readOneHandedModeEnabled(prefs); mOneHandedModeGravity = Settings.readOneHandedModeGravity(prefs); + mSecondaryLocale = Settings.getSecondaryLocale(prefs, RichInputMethodManager.getInstance().getCurrentSubtypeLocale().toString()); } public boolean isMetricsLoggingEnabled() { diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/ScriptUtils.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/ScriptUtils.java index 56a7b5efb..ed864b637 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/ScriptUtils.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/ScriptUtils.java @@ -201,6 +201,9 @@ public class ScriptUtils { * {@see http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes} */ public static int getScriptFromSpellCheckerLocale(final Locale locale) { + // need special treatment of serbian latin, which would get detected as cyrillic + if (locale.toString().toLowerCase(Locale.ENGLISH).equals("sr_zz")) + return ScriptUtils.SCRIPT_LATIN; String language = locale.getLanguage(); Integer script = mLanguageCodeToScriptCode.get(language); if (script == null) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00cbd741f..40a7e39ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -186,6 +186,14 @@ Perform a swipe from the delete key to select and remove bigger portions of text at once Space bar trackpad + + Multilingual typing + + Select a secondary dictionary to use alongside main language + + None + + No secondary dictionaries available Swipe on the spacebar to move the cursor diff --git a/app/src/main/res/xml/prefs_screen_advanced.xml b/app/src/main/res/xml/prefs_screen_advanced.xml index 6d13600b3..3831fc150 100644 --- a/app/src/main/res/xml/prefs_screen_advanced.xml +++ b/app/src/main/res/xml/prefs_screen_advanced.xml @@ -82,6 +82,12 @@ android:summary="@string/autospace_after_punctuation_summary" android:defaultValue="false" /> + +