Merge "Guard accessibility function calls"
diff --git a/java/res/values-fr/donottranslate-config-spacing-and-punctuations.xml b/java/res/values-fr/donottranslate-config-spacing-and-punctuations.xml
index d72f72b..5a49142 100644
--- a/java/res/values-fr/donottranslate-config-spacing-and-punctuations.xml
+++ b/java/res/values-fr/donottranslate-config-spacing-and-punctuations.xml
@@ -22,6 +22,8 @@
     <string name="symbols_preceded_by_space">([{&amp;;:!?</string>
     <!-- Symbols that are normally followed by a space (used to add an auto-space after these) -->
     <string name="symbols_followed_by_space">.,;:!?)]}&amp;</string>
+    <!-- Symbols that behave like a single punctuation when typed next to each other -->
+    <string name="symbols_clustering_together">!?</string>
     <!-- Symbols that separate words -->
     <!-- Don't remove the enclosing double quotes, they protect whitespace (not just U+0020) -->
     <string name="symbols_word_separators">"&#x0009;&#x0020;&#x000A;&#x00A0;"()[]{}*&amp;&lt;&gt;+=|.,;:!?/_\"</string>
diff --git a/java/res/values/donottranslate-config-spacing-and-punctuations.xml b/java/res/values/donottranslate-config-spacing-and-punctuations.xml
index 1be5cf8..2faf578 100644
--- a/java/res/values/donottranslate-config-spacing-and-punctuations.xml
+++ b/java/res/values/donottranslate-config-spacing-and-punctuations.xml
@@ -26,6 +26,8 @@
     <string name="symbols_preceded_by_space">([{&amp;</string>
     <!-- Symbols that are normally followed by a space (used to add an auto-space after these) -->
     <string name="symbols_followed_by_space">.,;:!?)]}&amp;</string>
+    <!-- Symbols that behave like a single punctuation when typed next to each other -->
+    <string name="symbols_clustering_together"></string>
     <!-- Symbols that separate words -->
     <!-- Don't remove the enclosing double quotes, they protect whitespace (not just U+0020) -->
     <string name="symbols_word_separators">"&#x0009;&#x0020;&#x000A;&#x00A0;"()[]{}*&amp;&lt;&gt;+=|.,;:!?/_\"</string>
diff --git a/java/src/com/android/inputmethod/latin/DictionaryFacilitatorForSuggest.java b/java/src/com/android/inputmethod/latin/DictionaryFacilitatorForSuggest.java
index b6fcbd1..c6b0d35 100644
--- a/java/src/com/android/inputmethod/latin/DictionaryFacilitatorForSuggest.java
+++ b/java/src/com/android/inputmethod/latin/DictionaryFacilitatorForSuggest.java
@@ -23,7 +23,7 @@
 import com.android.inputmethod.annotations.UsedForTesting;
 import com.android.inputmethod.keyboard.ProximityInfo;
 import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
-import com.android.inputmethod.latin.personalization.PersonalizationDictionary;
+import com.android.inputmethod.latin.personalization.DecayingExpandableBinaryDictionaryBase;
 import com.android.inputmethod.latin.personalization.PersonalizationHelper;
 import com.android.inputmethod.latin.personalization.UserHistoryDictionary;
 import com.android.inputmethod.latin.utils.CollectionUtils;
@@ -33,9 +33,11 @@
 
 import java.io.File;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -54,7 +56,7 @@
     // To synchronize assigning mDictionaries to ensure closing dictionaries.
     private Object mLock = new Object();
 
-    private static final String[] dictTypesOrderedToGetSuggestion =
+    private static final String[] DICT_TYPES_ORDERED_TO_GET_SUGGESTION =
             new String[] {
                 Dictionary.TYPE_MAIN,
                 Dictionary.TYPE_USER_HISTORY,
@@ -63,6 +65,10 @@
                 Dictionary.TYPE_CONTACTS
             };
 
+    private static final String[] SUB_DICT_TYPES =
+            Arrays.copyOfRange(DICT_TYPES_ORDERED_TO_GET_SUGGESTION, 1 /* start */,
+                    DICT_TYPES_ORDERED_TO_GET_SUGGESTION.length);
+
     /**
      * Class contains dictionaries for a locale.
      */
@@ -78,17 +84,13 @@
         }
 
         public Dictionaries(final Locale locale, final Dictionary mainDict,
-            final ExpandableBinaryDictionary contactsDict,
-            final ExpandableBinaryDictionary userDict,
-            final ExpandableBinaryDictionary userHistoryDict,
-            final ExpandableBinaryDictionary personalizationDict) {
+                final Map<String, ExpandableBinaryDictionary> subDicts) {
             mLocale = locale;
             // Main dictionary can be asynchronously loaded.
             setMainDict(mainDict);
-            setSubDict(Dictionary.TYPE_CONTACTS, contactsDict);
-            setSubDict(Dictionary.TYPE_USER, userDict);
-            setSubDict(Dictionary.TYPE_USER_HISTORY, userHistoryDict);
-            setSubDict(Dictionary.TYPE_PERSONALIZATION, personalizationDict);
+            for (final Map.Entry<String, ExpandableBinaryDictionary> entry : subDicts.entrySet()) {
+                setSubDict(entry.getKey(), entry.getValue());
+            }
         }
 
         private void setSubDict(final String dictType, final ExpandableBinaryDictionary dict) {
@@ -142,6 +144,21 @@
         return mDictionaries.mLocale;
     }
 
+    private static ExpandableBinaryDictionary getSubDict(final String dictType,
+            final Context context, final Locale locale, final File dictFile) {
+        if (Dictionary.TYPE_CONTACTS.equals(dictType)) {
+            return new ContactsBinaryDictionary(context, locale, dictFile);
+        } else if (Dictionary.TYPE_USER.equals(dictType)) {
+            return new UserBinaryDictionary(context, locale, dictFile);
+        } else if (Dictionary.TYPE_USER_HISTORY.equals(dictType)) {
+            return PersonalizationHelper.getUserHistoryDictionary(context, locale);
+        } else if (Dictionary.TYPE_PERSONALIZATION.equals(dictType)) {
+            return PersonalizationHelper.getPersonalizationDictionary(context, locale);
+        } else {
+            return null;
+        }
+    }
+
     public void resetDictionaries(final Context context, final Locale newLocale,
             final boolean useContactsDict, final boolean usePersonalizedDicts,
             final boolean forceReloadMainDictionary,
@@ -149,11 +166,15 @@
         final boolean localeHasBeenChanged = !newLocale.equals(mDictionaries.mLocale);
         // We always try to have the main dictionary. Other dictionaries can be unused.
         final boolean reloadMainDictionary = localeHasBeenChanged || forceReloadMainDictionary;
-        final boolean closeContactsDictionary = localeHasBeenChanged || !useContactsDict;
-        final boolean closeUserDictionary = localeHasBeenChanged;
-        final boolean closeUserHistoryDictionary = localeHasBeenChanged || !usePersonalizedDicts;
-        final boolean closePersonalizationDictionary =
-                localeHasBeenChanged || !usePersonalizedDicts;
+        final Set<String> subDictTypesToUse = CollectionUtils.newHashSet();
+        if (useContactsDict) {
+            subDictTypesToUse.add(Dictionary.TYPE_USER);
+        }
+        subDictTypesToUse.add(Dictionary.TYPE_USER);
+        if (usePersonalizedDicts) {
+            subDictTypesToUse.add(Dictionary.TYPE_USER_HISTORY);
+            subDictTypesToUse.add(Dictionary.TYPE_PERSONALIZATION);
+        }
 
         final Dictionary newMainDict;
         if (reloadMainDictionary) {
@@ -163,50 +184,25 @@
             newMainDict = mDictionaries.getMainDict();
         }
 
-        // Open or move contacts dictionary.
-        final ExpandableBinaryDictionary newContactsDict;
-        if (!closeContactsDictionary && mDictionaries.hasDict(Dictionary.TYPE_CONTACTS)) {
-            newContactsDict = mDictionaries.getSubDict(Dictionary.TYPE_CONTACTS);
-        } else if (useContactsDict) {
-            newContactsDict = new ContactsBinaryDictionary(context, newLocale);
-        } else {
-            newContactsDict = null;
-        }
-
-        // Open or move user dictionary.
-        final ExpandableBinaryDictionary newUserDictionary;
-        if (!closeUserDictionary && mDictionaries.hasDict(Dictionary.TYPE_USER)) {
-            newUserDictionary = mDictionaries.getSubDict(Dictionary.TYPE_USER);
-        } else {
-            newUserDictionary = new UserBinaryDictionary(context, newLocale);
-            mIsUserDictEnabled = UserBinaryDictionary.isEnabled(context);
-        }
-
-        // Open or move user history dictionary.
-        final ExpandableBinaryDictionary newUserHistoryDict;
-        if (!closeUserHistoryDictionary && mDictionaries.hasDict(Dictionary.TYPE_USER_HISTORY)) {
-            newUserHistoryDict = mDictionaries.getSubDict(Dictionary.TYPE_USER_HISTORY);
-        } else if (usePersonalizedDicts) {
-            newUserHistoryDict = PersonalizationHelper.getUserHistoryDictionary(context, newLocale);
-        } else {
-            newUserHistoryDict = null;
-        }
-
-        // Open or move personalization dictionary.
-        final ExpandableBinaryDictionary newPersonalizationDict;
-        if (!closePersonalizationDictionary
-                && mDictionaries.hasDict(Dictionary.TYPE_PERSONALIZATION)) {
-            newPersonalizationDict = mDictionaries.getSubDict(Dictionary.TYPE_PERSONALIZATION);
-        } else if (usePersonalizedDicts) {
-            newPersonalizationDict =
-                    PersonalizationHelper.getPersonalizationDictionary(context, newLocale);
-        } else {
-            newPersonalizationDict = null;
+        final Map<String, ExpandableBinaryDictionary> subDicts = CollectionUtils.newHashMap();
+        for (final String dictType : SUB_DICT_TYPES) {
+            if (!subDictTypesToUse.contains(dictType)) {
+                // This dictionary will not be used.
+                continue;
+            }
+            final ExpandableBinaryDictionary dict;
+            if (!localeHasBeenChanged && mDictionaries.hasDict(dictType)) {
+                // Continue to use current dictionary.
+                dict = mDictionaries.getSubDict(dictType);
+            } else {
+                // Start to use new dictionary.
+                dict = getSubDict(dictType, context, newLocale, null /* dictFile */);
+            }
+            subDicts.put(dictType, dict);
         }
 
         // Replace Dictionaries.
-        final Dictionaries newDictionaries = new Dictionaries(newLocale, newMainDict,
-                newContactsDict,  newUserDictionary, newUserHistoryDict, newPersonalizationDict);
+        final Dictionaries newDictionaries = new Dictionaries(newLocale, newMainDict, subDicts);
         final Dictionaries oldDictionaries;
         synchronized (mLock) {
             oldDictionaries = mDictionaries;
@@ -218,22 +214,14 @@
         if (listener != null) {
             listener.onUpdateMainDictionaryAvailability(hasInitializedMainDictionary());
         }
-
         // Clean up old dictionaries.
         if (reloadMainDictionary) {
             oldDictionaries.closeDict(Dictionary.TYPE_MAIN);
         }
-        if (closeContactsDictionary) {
-            oldDictionaries.closeDict(Dictionary.TYPE_CONTACTS);
-        }
-        if (closeUserDictionary) {
-            oldDictionaries.closeDict(Dictionary.TYPE_USER);
-        }
-        if (closeUserHistoryDictionary) {
-            oldDictionaries.closeDict(Dictionary.TYPE_USER_HISTORY);
-        }
-        if (closePersonalizationDictionary) {
-            oldDictionaries.closeDict(Dictionary.TYPE_PERSONALIZATION);
+        for (final String dictType : SUB_DICT_TYPES) {
+            if (localeHasBeenChanged || !subDictTypesToUse.contains(dictType)) {
+                oldDictionaries.closeDict(dictType);
+            }
         }
         oldDictionaries.mDictMap.clear();
         oldDictionaries.mSubDictMap.clear();
@@ -269,52 +257,28 @@
             final ArrayList<String> dictionaryTypes, final HashMap<String, File> dictionaryFiles,
             final Map<String, Map<String, String>> additionalDictAttributes) {
         Dictionary mainDictionary = null;
-        ContactsBinaryDictionary contactsDictionary = null;
-        UserBinaryDictionary userDictionary = null;
-        UserHistoryDictionary userHistoryDictionary = null;
-        PersonalizationDictionary personalizationDictionary = null;
+        final Map<String, ExpandableBinaryDictionary> subDicts = CollectionUtils.newHashMap();
 
         for (final String dictType : dictionaryTypes) {
             if (dictType.equals(Dictionary.TYPE_MAIN)) {
                 mainDictionary = DictionaryFactory.createMainDictionaryFromManager(context, locale);
-            } else if (dictType.equals(Dictionary.TYPE_USER_HISTORY)) {
-                userHistoryDictionary =
-                        PersonalizationHelper.getUserHistoryDictionary(context, locale);
-                // Staring with an empty user history dictionary for testing.
-                // Testing program may populate this dictionary before actual testing.
-                userHistoryDictionary.reloadDictionaryIfRequired();
-                userHistoryDictionary.waitAllTasksForTests();
-                if (additionalDictAttributes.containsKey(dictType)) {
-                    userHistoryDictionary.clearAndFlushDictionaryWithAdditionalAttributes(
-                            additionalDictAttributes.get(dictType));
-                }
-            } else if (dictType.equals(Dictionary.TYPE_PERSONALIZATION)) {
-                personalizationDictionary =
-                        PersonalizationHelper.getPersonalizationDictionary(context, locale);
-                // Staring with an empty personalization dictionary for testing.
-                // Testing program may populate this dictionary before actual testing.
-                personalizationDictionary.reloadDictionaryIfRequired();
-                personalizationDictionary.waitAllTasksForTests();
-                if (additionalDictAttributes.containsKey(dictType)) {
-                    personalizationDictionary.clearAndFlushDictionaryWithAdditionalAttributes(
-                            additionalDictAttributes.get(dictType));
-                }
-            } else if (dictType.equals(Dictionary.TYPE_USER)) {
-                final File file = dictionaryFiles.get(dictType);
-                userDictionary = new UserBinaryDictionary(context, locale, file);
-                userDictionary.reloadDictionaryIfRequired();
-                userDictionary.waitAllTasksForTests();
-            } else if (dictType.equals(Dictionary.TYPE_CONTACTS)) {
-                final File file = dictionaryFiles.get(dictType);
-                contactsDictionary = new ContactsBinaryDictionary(context, locale, file);
-                contactsDictionary.reloadDictionaryIfRequired();
-                contactsDictionary.waitAllTasksForTests();
             } else {
-                throw new RuntimeException("Unknown dictionary type: " + dictType);
+                final File dictFile = dictionaryFiles.get(dictType);
+                final ExpandableBinaryDictionary dict = getSubDict(
+                        dictType, context, locale, dictFile);
+                if (additionalDictAttributes.containsKey(dictType)) {
+                    dict.clearAndFlushDictionaryWithAdditionalAttributes(
+                            additionalDictAttributes.get(dictType));
+                }
+                if (dict == null) {
+                    throw new RuntimeException("Unknown dictionary type: " + dictType);
+                }
+                dict.reloadDictionaryIfRequired();
+                dict.waitAllTasksForTests();
+                subDicts.put(dictType, dict);
             }
         }
-        mDictionaries = new Dictionaries(locale, mainDictionary, contactsDictionary,
-                userDictionary, userHistoryDictionary, personalizationDictionary);
+        mDictionaries = new Dictionaries(locale, mainDictionary, subDicts);
     }
 
     public void closeDictionaries() {
@@ -443,7 +407,7 @@
         final SuggestionResults suggestionResults =
                 new SuggestionResults(dictionaries.mLocale, SuggestedWords.MAX_SUGGESTIONS);
         final float[] languageWeight = new float[] { Dictionary.NOT_A_LANGUAGE_WEIGHT };
-        for (final String dictType : dictTypesOrderedToGetSuggestion) {
+        for (final String dictType : DICT_TYPES_ORDERED_TO_GET_SUGGESTION) {
             final Dictionary dictionary = dictMap.get(dictType);
             if (null == dictionary) continue;
             final ArrayList<SuggestedWordInfo> dictionarySuggestions =
diff --git a/java/src/com/android/inputmethod/latin/ExpandableBinaryDictionary.java b/java/src/com/android/inputmethod/latin/ExpandableBinaryDictionary.java
index 550db4a..cda429e 100644
--- a/java/src/com/android/inputmethod/latin/ExpandableBinaryDictionary.java
+++ b/java/src/com/android/inputmethod/latin/ExpandableBinaryDictionary.java
@@ -97,6 +97,8 @@
 
     private final ReentrantReadWriteLock mLock;
 
+    private Map<String, String> mAdditionalAttributeMap = null;
+
     /* A extension for a binary dictionary file. */
     protected static final String DICT_FILE_EXTENSION = ".dict";
 
@@ -196,6 +198,9 @@
 
     protected Map<String, String> getHeaderAttributeMap() {
         HashMap<String, String> attributeMap = new HashMap<String, String>();
+        if (mAdditionalAttributeMap != null) {
+            attributeMap.putAll(mAdditionalAttributeMap);
+        }
         attributeMap.put(DictionaryHeader.DICTIONARY_ID_KEY, mDictName);
         attributeMap.put(DictionaryHeader.DICTIONARY_LOCALE_KEY, mLocale.toString());
         attributeMap.put(DictionaryHeader.DICTIONARY_VERSION_KEY,
@@ -592,6 +597,12 @@
     }
 
     @UsedForTesting
+    public void clearAndFlushDictionaryWithAdditionalAttributes(
+            final Map<String, String> attributeMap) {
+        mAdditionalAttributeMap = attributeMap;
+        clear();
+    }
+
     public void dumpAllWordsForDebug() {
         reloadDictionaryIfRequired();
         asyncExecuteTaskWithLock(mLock.readLock(), new Runnable() {
diff --git a/java/src/com/android/inputmethod/latin/inputlogic/InputLogic.java b/java/src/com/android/inputmethod/latin/inputlogic/InputLogic.java
index d2100d4..75432fb 100644
--- a/java/src/com/android/inputmethod/latin/inputlogic/InputLogic.java
+++ b/java/src/com/android/inputmethod/latin/inputlogic/InputLogic.java
@@ -784,11 +784,11 @@
             // TODO: remove this argument
             final LatinIME.UIHandler handler) {
         final int codePoint = inputTransaction.mEvent.mCodePoint;
+        final SettingsValues settingsValues = inputTransaction.mSettingsValues;
         boolean didAutoCorrect = false;
         // We avoid sending spaces in languages without spaces if we were composing.
         final boolean shouldAvoidSendingCode = Constants.CODE_SPACE == codePoint
-                && !inputTransaction.mSettingsValues.mSpacingAndPunctuations
-                        .mCurrentLanguageHasSpaces
+                && !settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces
                 && mWordComposer.isComposingWord();
         if (mWordComposer.isCursorFrontOrMiddleOfComposingWord()) {
             // If we are in the middle of a recorrection, we need to commit the recorrection
@@ -798,13 +798,13 @@
         }
         // isComposingWord() may have changed since we stored wasComposing
         if (mWordComposer.isComposingWord()) {
-            if (inputTransaction.mSettingsValues.mCorrectionEnabled) {
+            if (settingsValues.mCorrectionEnabled) {
                 final String separator = shouldAvoidSendingCode ? LastComposedWord.NOT_A_SEPARATOR
                         : StringUtils.newSingleCodePointString(codePoint);
-                commitCurrentAutoCorrection(inputTransaction.mSettingsValues, separator, handler);
+                commitCurrentAutoCorrection(settingsValues, separator, handler);
                 didAutoCorrect = true;
             } else {
-                commitTyped(inputTransaction.mSettingsValues,
+                commitTyped(settingsValues,
                         StringUtils.newSingleCodePointString(codePoint));
             }
         }
@@ -821,20 +821,23 @@
             // Double quotes behave like they are usually preceded by space iff we are
             // not inside a double quote or after a digit.
             needsPrecedingSpace = !isInsideDoubleQuoteOrAfterDigit;
+        } else if (settingsValues.mSpacingAndPunctuations.isClusteringSymbol(codePoint)
+                && settingsValues.mSpacingAndPunctuations.isClusteringSymbol(
+                        mConnection.getCodePointBeforeCursor())) {
+            needsPrecedingSpace = false;
         } else {
-            needsPrecedingSpace = inputTransaction.mSettingsValues.isUsuallyPrecededBySpace(
-                    codePoint);
+            needsPrecedingSpace = settingsValues.isUsuallyPrecededBySpace(codePoint);
         }
 
         if (needsPrecedingSpace) {
-            promotePhantomSpace(inputTransaction.mSettingsValues);
+            promotePhantomSpace(settingsValues);
         }
         if (ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS) {
             ResearchLogger.latinIME_handleSeparator(codePoint, mWordComposer.isComposingWord());
         }
 
         if (!shouldAvoidSendingCode) {
-            sendKeyCodePoint(inputTransaction.mSettingsValues, codePoint);
+            sendKeyCodePoint(settingsValues, codePoint);
         }
 
         if (Constants.CODE_SPACE == codePoint) {
@@ -852,7 +855,7 @@
                 swapSwapperAndSpace(inputTransaction);
                 mSpaceState = SpaceState.SWAP_PUNCTUATION;
             } else if ((SpaceState.PHANTOM == inputTransaction.mSpaceState
-                    && inputTransaction.mSettingsValues.isUsuallyFollowedBySpace(codePoint))
+                    && settingsValues.isUsuallyFollowedBySpace(codePoint))
                     || (Constants.CODE_DOUBLE_QUOTE == codePoint
                             && isInsideDoubleQuoteOrAfterDigit)) {
                 // If we are in phantom space state, and the user presses a separator, we want to
diff --git a/java/src/com/android/inputmethod/latin/personalization/DecayingExpandableBinaryDictionaryBase.java b/java/src/com/android/inputmethod/latin/personalization/DecayingExpandableBinaryDictionaryBase.java
index 352288f..38c28a7 100644
--- a/java/src/com/android/inputmethod/latin/personalization/DecayingExpandableBinaryDictionaryBase.java
+++ b/java/src/com/android/inputmethod/latin/personalization/DecayingExpandableBinaryDictionaryBase.java
@@ -18,15 +18,11 @@
 
 import android.content.Context;
 
-import com.android.inputmethod.annotations.UsedForTesting;
-import com.android.inputmethod.latin.Constants;
 import com.android.inputmethod.latin.Dictionary;
 import com.android.inputmethod.latin.ExpandableBinaryDictionary;
 import com.android.inputmethod.latin.makedict.DictionaryHeader;
-import com.android.inputmethod.latin.utils.LanguageModelParam;
 
 import java.io.File;
-import java.util.ArrayList;
 import java.util.Locale;
 import java.util.Map;
 
@@ -47,8 +43,6 @@
     /** The locale for this dictionary. */
     public final Locale mLocale;
 
-    private Map<String, String> mAdditionalAttributeMap = null;
-
     protected DecayingExpandableBinaryDictionaryBase(final Context context,
             final String dictName, final Locale locale, final String dictionaryType,
             final File dictFile) {
@@ -72,9 +66,6 @@
     @Override
     protected Map<String, String> getHeaderAttributeMap() {
         final Map<String, String> attributeMap = super.getHeaderAttributeMap();
-        if (mAdditionalAttributeMap != null) {
-            attributeMap.putAll(mAdditionalAttributeMap);
-        }
         attributeMap.put(DictionaryHeader.USES_FORGETTING_CURVE_KEY,
                 DictionaryHeader.ATTRIBUTE_VALUE_TRUE);
         attributeMap.put(DictionaryHeader.HAS_HISTORICAL_INFO_KEY,
@@ -92,13 +83,6 @@
         // No initial contents.
     }
 
-    @UsedForTesting
-    public void clearAndFlushDictionaryWithAdditionalAttributes(
-            final Map<String, String> attributeMap) {
-        mAdditionalAttributeMap = attributeMap;
-        clear();
-    }
-
     /* package */ void runGCIfRequired() {
         runGCIfRequired(false /* mindsBlockByGC */);
     }
diff --git a/java/src/com/android/inputmethod/latin/settings/SpacingAndPunctuations.java b/java/src/com/android/inputmethod/latin/settings/SpacingAndPunctuations.java
index 796921f..b8d2a22 100644
--- a/java/src/com/android/inputmethod/latin/settings/SpacingAndPunctuations.java
+++ b/java/src/com/android/inputmethod/latin/settings/SpacingAndPunctuations.java
@@ -30,6 +30,7 @@
 public final class SpacingAndPunctuations {
     private final int[] mSortedSymbolsPrecededBySpace;
     private final int[] mSortedSymbolsFollowedBySpace;
+    private final int[] mSortedSymbolsClusteringTogether;
     private final int[] mSortedWordConnectors;
     public final int[] mSortedWordSeparators;
     public final PunctuationSuggestions mSuggestPuncList;
@@ -46,6 +47,8 @@
         // To be able to binary search the code point. See {@link #isUsuallyFollowedBySpace(int)}.
         mSortedSymbolsFollowedBySpace = StringUtils.toSortedCodePointArray(
                 res.getString(R.string.symbols_followed_by_space));
+        mSortedSymbolsClusteringTogether = StringUtils.toSortedCodePointArray(
+                res.getString(R.string.symbols_clustering_together));
         // To be able to binary search the code point. See {@link #isWordConnector(int)}.
         mSortedWordConnectors = StringUtils.toSortedCodePointArray(
                 res.getString(R.string.symbols_word_connectors));
@@ -85,6 +88,10 @@
         return Arrays.binarySearch(mSortedSymbolsFollowedBySpace, code) >= 0;
     }
 
+    public boolean isClusteringSymbol(final int code) {
+        return Arrays.binarySearch(mSortedSymbolsClusteringTogether, code) >= 0;
+    }
+
     public boolean isSentenceSeparator(final int code) {
         return code == mSentenceSeparator;
     }
diff --git a/tests/src/com/android/inputmethod/latin/InputLogicTests.java b/tests/src/com/android/inputmethod/latin/InputLogicTests.java
index d2dd292..29423e8 100644
--- a/tests/src/com/android/inputmethod/latin/InputLogicTests.java
+++ b/tests/src/com/android/inputmethod/latin/InputLogicTests.java
@@ -334,6 +334,18 @@
         assertEquals("manual pick then separator", EXPECTED_RESULT, mEditText.getText().toString());
     }
 
+    // This test matches the one in InputLogicTestsNonEnglish. In some non-English languages,
+    // ! and ? are clustering punctuation signs.
+    public void testClusteringPunctuation() {
+        final String WORD1_TO_TYPE = "test";
+        final String WORD2_TO_TYPE = "!!?!:!";
+        final String EXPECTED_RESULT = "test!!?!:!";
+        type(WORD1_TO_TYPE);
+        pickSuggestionManually(0, WORD1_TO_TYPE);
+        type(WORD2_TO_TYPE);
+        assertEquals("clustering punctuation", EXPECTED_RESULT, mEditText.getText().toString());
+    }
+
     public void testManualPickThenStripperThenPick() {
         final String WORD_TO_TYPE = "this";
         final String STRIPPER = "\n";
diff --git a/tests/src/com/android/inputmethod/latin/InputLogicTestsNonEnglish.java b/tests/src/com/android/inputmethod/latin/InputLogicTestsNonEnglish.java
index 1257ae2..68b6ee6 100644
--- a/tests/src/com/android/inputmethod/latin/InputLogicTestsNonEnglish.java
+++ b/tests/src/com/android/inputmethod/latin/InputLogicTestsNonEnglish.java
@@ -45,6 +45,19 @@
                 mEditText.getText().toString());
     }
 
+    public void testClusteringPunctuationForFrench() {
+        final String WORD1_TO_TYPE = "test";
+        final String WORD2_TO_TYPE = "!!?!:!";
+        // In English, the expected result would be "test!!?!:!"
+        final String EXPECTED_RESULT = "test !!?! : !";
+        changeLanguage("fr");
+        type(WORD1_TO_TYPE);
+        pickSuggestionManually(0, WORD1_TO_TYPE);
+        type(WORD2_TO_TYPE);
+        assertEquals("clustering punctuation for French", EXPECTED_RESULT,
+                mEditText.getText().toString());
+    }
+
     public void testWordThenSpaceThenPunctuationFromStripTwiceForFrench() {
         final String WORD_TO_TYPE = "test ";
         final String PUNCTUATION_FROM_STRIP = "!";