Merge "Add unit test of KeyboardState"
diff --git a/java/src/com/android/inputmethod/keyboard/Key.java b/java/src/com/android/inputmethod/keyboard/Key.java
index b2b68f0..ced7638 100644
--- a/java/src/com/android/inputmethod/keyboard/Key.java
+++ b/java/src/com/android/inputmethod/keyboard/Key.java
@@ -22,6 +22,7 @@
 import android.graphics.Typeface;
 import android.graphics.drawable.Drawable;
 import android.text.TextUtils;
+import android.util.Log;
 import android.util.Xml;
 
 import com.android.inputmethod.keyboard.internal.KeyStyles;
@@ -42,6 +43,8 @@
  * Class for describing the position and characteristics of a single key in the keyboard.
  */
 public class Key {
+    private static final String TAG = Key.class.getSimpleName();
+
     /**
      * The key code (unicode or custom code) that this key generates.
      */
@@ -284,7 +287,11 @@
         // specified.
         final int code = style.getInt(keyAttr, R.styleable.Keyboard_Key_code,
                 Keyboard.CODE_UNSPECIFIED);
-        if (code == Keyboard.CODE_UNSPECIFIED && !TextUtils.isEmpty(mLabel)) {
+        if (code == Keyboard.CODE_UNSPECIFIED && mOutputText == null
+                && !TextUtils.isEmpty(mLabel)) {
+            if (mLabel.length() != 1) {
+                Log.w(TAG, "Label is not a single letter: label=" + mLabel);
+            }
             final int firstChar = mLabel.charAt(0);
             mCode = getRtlParenthesisCode(firstChar, params.mIsRtlKeyboard);
         } else if (code != Keyboard.CODE_UNSPECIFIED) {
diff --git a/java/src/com/android/inputmethod/keyboard/KeyDetector.java b/java/src/com/android/inputmethod/keyboard/KeyDetector.java
index 2a6e0a2..8e325b6 100644
--- a/java/src/com/android/inputmethod/keyboard/KeyDetector.java
+++ b/java/src/com/android/inputmethod/keyboard/KeyDetector.java
@@ -222,13 +222,7 @@
     }
 
     public static String printableCode(Key key) {
-        return key != null ? printableCode(key.mCode) : "none";
-    }
-
-    public static String printableCode(int primaryCode) {
-        if (primaryCode < 0) return String.format("%4d", primaryCode);
-        if (primaryCode < 0x100) return String.format("\\u%02x", primaryCode);
-        return String.format("\\u04x", primaryCode);
+        return key != null ? Keyboard.printableCode(key.mCode) : "none";
     }
 
     public static String printableCodes(int[] codes) {
diff --git a/java/src/com/android/inputmethod/keyboard/Keyboard.java b/java/src/com/android/inputmethod/keyboard/Keyboard.java
index f234507..e267aa6 100644
--- a/java/src/com/android/inputmethod/keyboard/Keyboard.java
+++ b/java/src/com/android/inputmethod/keyboard/Keyboard.java
@@ -18,6 +18,7 @@
 
 import android.graphics.drawable.Drawable;
 import android.text.TextUtils;
+import android.util.Log;
 
 import com.android.inputmethod.keyboard.internal.KeyboardIconsSet;
 import com.android.inputmethod.keyboard.internal.KeyboardParams;
@@ -48,6 +49,8 @@
  * </pre>
  */
 public class Keyboard {
+    private static final String TAG = Keyboard.class.getSimpleName();
+
     /** Some common keys code.  These should be aligned with values/keycodes.xml */
     public static final int CODE_ENTER = '\n';
     public static final int CODE_TAB = '\t';
@@ -241,4 +244,20 @@
         default: return null;
         }
     }
+
+    public static String printableCode(int code) {
+        switch (code) {
+        case CODE_SHIFT: return "shift";
+        case CODE_SWITCH_ALPHA_SYMBOL: return "symbol";
+        case CODE_CAPSLOCK: return "capslock";
+        case CODE_DELETE: return "delete";
+        case CODE_SHORTCUT: return "shortcut";
+        case CODE_DUMMY: return "dummy";
+        case CODE_UNSPECIFIED: return "unspec";
+        default:
+            if (code < 0) Log.w(TAG, "Unknow negative key code=" + code);
+            if (code < 0x100) return String.format("\\u%02x", code);
+            return String.format("\\u04x", code);
+        }
+    }
 }
diff --git a/java/src/com/android/inputmethod/keyboard/PointerTracker.java b/java/src/com/android/inputmethod/keyboard/PointerTracker.java
index 9e0c5ce..3a07cdf 100644
--- a/java/src/com/android/inputmethod/keyboard/PointerTracker.java
+++ b/java/src/com/android/inputmethod/keyboard/PointerTracker.java
@@ -239,7 +239,7 @@
     private boolean callListenerOnPressAndCheckKeyboardLayoutChange(Key key, boolean withSliding) {
         final boolean ignoreModifierKey = mIgnoreModifierKey && key.isModifier();
         if (DEBUG_LISTENER) {
-            Log.d(TAG, "onPress    : " + KeyDetector.printableCode(key.mCode)
+            Log.d(TAG, "onPress    : " + KeyDetector.printableCode(key)
                     + " sliding=" + withSliding + " ignoreModifier=" + ignoreModifierKey
                     + " enabled=" + key.isEnabled());
         }
@@ -264,7 +264,7 @@
         // If code is CODE_DUMMY here, this key will be ignored or generate text.
         final CharSequence text = (code != Keyboard.CODE_DUMMY) ? null : key.mOutputText;
         if (DEBUG_LISTENER) {
-            Log.d(TAG, "onCodeInput: " + KeyDetector.printableCode(code) + " text=" + text
+            Log.d(TAG, "onCodeInput: " + Keyboard.printableCode(code) + " text=" + text
                     + " codes="+ KeyDetector.printableCodes(keyCodes) + " x=" + x + " y=" + y
                     + " ignoreModifier=" + ignoreModifierKey + " alterCode=" + alterCode
                     + " enabled=" + key.isEnabled());
@@ -289,7 +289,7 @@
     private void callListenerOnRelease(Key key, int primaryCode, boolean withSliding) {
         final boolean ignoreModifierKey = mIgnoreModifierKey && key.isModifier();
         if (DEBUG_LISTENER) {
-            Log.d(TAG, "onRelease  : " + KeyDetector.printableCode(primaryCode)
+            Log.d(TAG, "onRelease  : " + Keyboard.printableCode(primaryCode)
                     + " sliding=" + withSliding + " ignoreModifier=" + ignoreModifierKey
                     + " enabled="+ key.isEnabled());
         }
diff --git a/java/src/com/android/inputmethod/latin/LatinIME.java b/java/src/com/android/inputmethod/latin/LatinIME.java
index 54989bb..b4ad225 100644
--- a/java/src/com/android/inputmethod/latin/LatinIME.java
+++ b/java/src/com/android/inputmethod/latin/LatinIME.java
@@ -203,13 +203,12 @@
     private boolean mIsSettingsSuggestionStripOn;
     private boolean mApplicationSpecifiedCompletionOn;
 
-    private final StringBuilder mComposingStringBuilder = new StringBuilder();
     private WordComposer mWordComposer = new WordComposer();
     private CharSequence mBestWord;
     private boolean mHasUncommittedTypedChars;
 
     private int mCorrectionMode;
-    private int mCommittedLength;
+    private String mWordSavedForAutoCorrectCancellation;
     // Keep track of the last selection range to decide if we need to show word alternatives
     private int mLastSelectionStart;
     private int mLastSelectionEnd;
@@ -756,7 +755,7 @@
 
         inputView.closing();
         mEnteredText = null;
-        mComposingStringBuilder.setLength(0);
+        mWordComposer.reset();
         mHasUncommittedTypedChars = false;
         mDeleteCount = 0;
         mSpaceState = SPACE_STATE_NONE;
@@ -928,10 +927,10 @@
                 // newly inserted punctuation.
                 mSpaceState = SPACE_STATE_NONE;
             }
-            if (((mComposingStringBuilder.length() > 0 && mHasUncommittedTypedChars)
+            if (((mWordComposer.size() > 0 && mHasUncommittedTypedChars)
                     || mVoiceProxy.isVoiceInputHighlighted())
                     && (selectionChanged || candidatesCleared)) {
-                mComposingStringBuilder.setLength(0);
+                mWordComposer.reset();
                 mHasUncommittedTypedChars = false;
                 TextEntryState.reset();
                 updateSuggestions();
@@ -1146,13 +1145,13 @@
     public void commitTyped(final InputConnection ic) {
         if (!mHasUncommittedTypedChars) return;
         mHasUncommittedTypedChars = false;
-        if (mComposingStringBuilder.length() > 0) {
+        final CharSequence typedWord = mWordComposer.getTypedWord();
+        if (typedWord.length() > 0) {
             if (ic != null) {
-                ic.commitText(mComposingStringBuilder, 1);
+                ic.commitText(typedWord, 1);
             }
-            mCommittedLength = mComposingStringBuilder.length();
-            TextEntryState.acceptedTyped(mComposingStringBuilder);
-            addToUserUnigramAndBigramDictionaries(mComposingStringBuilder,
+            TextEntryState.acceptedTyped(typedWord);
+            addToUserUnigramAndBigramDictionaries(typedWord,
                     UserUnigramDictionary.FREQUENCY_FOR_TYPED);
         }
         updateSuggestions();
@@ -1403,17 +1402,16 @@
 
         final boolean deleteChar = !mHasUncommittedTypedChars;
         if (mHasUncommittedTypedChars) {
-            final int length = mComposingStringBuilder.length();
+            final int length = mWordComposer.size();
             if (length > 0) {
-                mComposingStringBuilder.delete(length - 1, length);
                 mWordComposer.deleteLast();
                 final CharSequence textWithUnderline =
                         mComposingStateManager.isAutoCorrectionIndicatorOn()
                                 ? SuggestionSpanUtils.getTextWithAutoCorrectionIndicatorUnderline(
-                                            this, mComposingStringBuilder)
-                                : mComposingStringBuilder;
+                                            this, mWordComposer.getTypedWord())
+                                : mWordComposer.getTypedWord();
                 ic.setComposingText(textWithUnderline, 1);
-                if (mComposingStringBuilder.length() == 0) {
+                if (mWordComposer.size() == 0) {
                     mHasUncommittedTypedChars = false;
                 }
                 if (1 == length) {
@@ -1432,11 +1430,15 @@
 
         // TODO: Merge space state with TextEntryState
         TextEntryState.backspace();
-        if (TextEntryState.isUndoCommit()) {
-            revertLastWord(ic);
+        if (null != mWordSavedForAutoCorrectCancellation) {
+            cancelAutoCorrect(ic);
+            mWordSavedForAutoCorrectCancellation = null;
             ic.endBatchEdit();
             return;
+        } else {
+            mWordSavedForAutoCorrectCancellation = null;
         }
+
         if (SPACE_STATE_DOUBLE == spaceState) {
             if (revertDoubleSpace(ic)) {
                 ic.endBatchEdit();
@@ -1453,6 +1455,9 @@
         }
 
         if (mEnteredText != null && sameAsTextBeforeCursor(ic, mEnteredText)) {
+            // Cancel multi-character input: remove the text we just entered.
+            // This is triggered on backspace after a key that inputs multiple characters,
+            // like the smiley key or the .com key.
             ic.deleteSurroundingText(mEnteredText.length(), 0);
         } else if (deleteChar) {
             if (mSuggestionsView != null && mSuggestionsView.dismissAddToDictionaryHint()) {
@@ -1463,7 +1468,7 @@
                 // different behavior only in the case of picking the first
                 // suggestion (typed word).  It's intentional to have made this
                 // inconsistent with backspacing after selecting other suggestions.
-                revertLastWord(ic);
+                restartSuggestionsOnManuallyPickedTypedWord(ic);
             } else {
                 ic.deleteSurroundingText(1, 0);
                 if (mDeleteCount > DELETE_ACCELERATE_AT) {
@@ -1516,7 +1521,6 @@
                 // Reset entirely the composing state anyway, then start composing a new word unless
                 // the character is a single quote.
                 mHasUncommittedTypedChars = (Keyboard.CODE_SINGLE_QUOTE != code);
-                mComposingStringBuilder.setLength(0);
                 mWordComposer.reset();
                 clearSuggestions();
                 mComposingStateManager.onFinishComposingText();
@@ -1546,7 +1550,6 @@
             }
         }
         if (mHasUncommittedTypedChars) {
-            mComposingStringBuilder.append((char) code);
             mWordComposer.add(code, keyCodes, x, y);
             if (ic != null) {
                 // If it's the first letter, make note of auto-caps state
@@ -1557,8 +1560,8 @@
                 final CharSequence textWithUnderline =
                         mComposingStateManager.isAutoCorrectionIndicatorOn()
                                 ? SuggestionSpanUtils.getTextWithAutoCorrectionIndicatorUnderline(
-                                        this, mComposingStringBuilder)
-                                : mComposingStringBuilder;
+                                        this, mWordComposer.getTypedWord())
+                                : mWordComposer.getTypedWord();
                 ic.setComposingText(textWithUnderline, 1);
             }
             mHandler.postUpdateSuggestions();
@@ -1604,6 +1607,8 @@
             } else {
                 commitTyped(ic);
             }
+        } else {
+            mWordSavedForAutoCorrectCancellation = null;
         }
 
         final boolean swapMagicSpace;
@@ -1746,8 +1751,8 @@
                 }
                 final CharSequence textWithUnderline = newAutoCorrectionIndicator
                         ? SuggestionSpanUtils.getTextWithAutoCorrectionIndicatorUnderline(
-                                this, mComposingStringBuilder)
-                        : mComposingStringBuilder;
+                                this, mWordComposer.getTypedWord())
+                        : mWordComposer.getTypedWord();
                 if (!TextUtils.isEmpty(textWithUnderline)) {
                     ic.setComposingText(textWithUnderline, 1);
                 }
@@ -1866,6 +1871,9 @@
             TextEntryState.acceptedDefault(mWordComposer.getTypedWord(), mBestWord, separatorCode);
             mExpectingUpdateSelection = true;
             commitBestWord(mBestWord);
+            if (!mBestWord.equals(mWordComposer.getTypedWord())) {
+                mWordSavedForAutoCorrectCancellation = mBestWord.toString();
+            }
             // Add the word to the user unigram dictionary if it's not a known word
             addToUserUnigramAndBigramDictionaries(mBestWord,
                     UserUnigramDictionary.FREQUENCY_FOR_TYPED);
@@ -1891,7 +1899,6 @@
                 final CompletionInfo completionInfo = mApplicationSpecifiedCompletions[index];
                 ic.commitCompletion(completionInfo);
             }
-            mCommittedLength = suggestion.length();
             if (mSuggestionsView != null) {
                 mSuggestionsView.clear();
             }
@@ -1940,9 +1947,9 @@
         } else {
             addToOnlyBigramDictionary(suggestion, 1);
         }
-        LatinImeLogger.logOnManualSuggestion(mComposingStringBuilder.toString(),
+        LatinImeLogger.logOnManualSuggestion(mWordComposer.getTypedWord().toString(),
                 suggestion.toString(), index, suggestions.mWords);
-        TextEntryState.acceptedSuggestion(mComposingStringBuilder.toString(), suggestion);
+        TextEntryState.acceptedSuggestion(mWordComposer.getTypedWord().toString(), suggestion);
         // Follow it with a space
         if (mInsertSpaceOnPickSuggestionManually) {
             sendMagicSpace();
@@ -2009,7 +2016,6 @@
             }
         }
         mHasUncommittedTypedChars = false;
-        mCommittedLength = bestWord.length();
     }
 
     private static final WordComposer sEmptyWordComposer = new WordComposer();
@@ -2152,8 +2158,6 @@
     private void restartSuggestionsOnWordBeforeCursor(final InputConnection ic,
             final CharSequence word) {
         mWordComposer.setComposingWord(word, mKeyboardSwitcher.getLatinKeyboard());
-        mComposingStringBuilder.setLength(0);
-        mComposingStringBuilder.append(word);
         // mBestWord will be set appropriately by updateSuggestions() called by the handler
         mBestWord = null;
         mHasUncommittedTypedChars = true;
@@ -2165,39 +2169,61 @@
     }
 
     // "ic" must not be null
-    private void revertLastWord(final InputConnection ic) {
-        if (mHasUncommittedTypedChars || mComposingStringBuilder.length() <= 0) {
-            sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL);
-            return;
-        }
-
+    private void cancelAutoCorrect(final InputConnection ic) {
+        final int cancelLength = mWordSavedForAutoCorrectCancellation.length();
         final CharSequence separator = ic.getTextBeforeCursor(1, 0);
-        ic.deleteSurroundingText(1, 0);
-        final CharSequence textToTheLeft = ic.getTextBeforeCursor(mCommittedLength, 0);
-        ic.deleteSurroundingText(mCommittedLength, 0);
-
-        // Re-insert "separator" only when the deleted character was word separator and the
-        // composing text wasn't equal to the auto-corrected text which can be found before
-        // the cursor.
-        if (!TextUtils.isEmpty(separator)
-                && mSettingsValues.isWordSeparator(separator.charAt(0))
-                && !TextUtils.equals(mComposingStringBuilder, textToTheLeft)) {
-            ic.commitText(mComposingStringBuilder, 1);
-            TextEntryState.acceptedTyped(mComposingStringBuilder);
-            ic.commitText(separator, 1);
-            TextEntryState.typedCharacter(separator.charAt(0), true,
-                    WordComposer.NOT_A_COORDINATE, WordComposer.NOT_A_COORDINATE);
-            // Clear composing text
-            mComposingStringBuilder.setLength(0);
-        } else {
-            // Note: this relies on the last word still being held in the WordComposer
-            // Note: in the interest of code simplicity, we may want to just call
-            // restartSuggestionsOnWordBeforeCursorIfAtEndOfWord instead, but retrieving
-            // the old WordComposer allows to reuse the actual typed coordinates.
-            mHasUncommittedTypedChars = true;
-            ic.setComposingText(mComposingStringBuilder, 1);
-            TextEntryState.backspace();
+        if (DEBUG) {
+            final String wordBeforeCursor =
+                    ic.getTextBeforeCursor(cancelLength + 1, 0).subSequence(0, cancelLength)
+                    .toString();
+            if (!mWordSavedForAutoCorrectCancellation.equals(wordBeforeCursor)) {
+                throw new RuntimeException("cancelAutoCorrect check failed: we thought we were "
+                        + "reverting \"" + mWordSavedForAutoCorrectCancellation
+                        + "\", but before the cursor we found \"" + wordBeforeCursor + "\"");
+            }
+            if (mWordComposer.getTypedWord().equals(wordBeforeCursor)) {
+                throw new RuntimeException("cancelAutoCorrect check failed: we wanted to cancel "
+                        + "auto correction and revert to \"" + mWordComposer.getTypedWord()
+                        + "\" but we found this very string before the cursor");
+            }
         }
+        ic.deleteSurroundingText(cancelLength + 1, 0);
+
+        // Re-insert the separator
+        ic.commitText(mWordComposer.getTypedWord(), 1);
+        TextEntryState.acceptedTyped(mWordComposer.getTypedWord());
+        ic.commitText(separator, 1);
+        TextEntryState.typedCharacter(separator.charAt(0), true,
+                WordComposer.NOT_A_COORDINATE, WordComposer.NOT_A_COORDINATE);
+        mHandler.cancelUpdateBigramPredictions();
+        mHandler.postUpdateSuggestions();
+    }
+
+    // "ic" must not be null
+    private void restartSuggestionsOnManuallyPickedTypedWord(final InputConnection ic) {
+        final CharSequence separator = ic.getTextBeforeCursor(1, 0);
+        final int restartLength = mWordComposer.size();
+        if (DEBUG) {
+            final String wordBeforeCursor =
+                ic.getTextBeforeCursor(restartLength + 1, 0).subSequence(0, restartLength)
+                .toString();
+            if (!mWordComposer.getTypedWord().equals(wordBeforeCursor)) {
+                throw new RuntimeException("restartSuggestionsOnManuallyPickedTypedWord "
+                        + "check failed: we thought we were reverting \""
+                        + mWordComposer.getTypedWord()
+                        + "\", but before the cursor we found \""
+                        + wordBeforeCursor + "\"");
+            }
+        }
+        ic.deleteSurroundingText(restartLength + 1, 0);
+
+        // Note: this relies on the last word still being held in the WordComposer
+        // Note: in the interest of code simplicity, we may want to just call
+        // restartSuggestionsOnWordBeforeCursorIfAtEndOfWord instead, but retrieving
+        // the old WordComposer allows to reuse the actual typed coordinates.
+        mHasUncommittedTypedChars = true;
+        ic.setComposingText(mWordComposer.getTypedWord(), 1);
+        TextEntryState.backspace();
         mHandler.cancelUpdateBigramPredictions();
         mHandler.postUpdateSuggestions();
     }
@@ -2474,7 +2500,6 @@
         final Keyboard keyboard = mKeyboardSwitcher.getLatinKeyboard();
         final int keyboardMode = keyboard != null ? keyboard.mId.mMode : -1;
         p.println("  Keyboard mode = " + keyboardMode);
-        p.println("  mComposingStringBuilder=" + mComposingStringBuilder.toString());
         p.println("  mIsSuggestionsRequested=" + mIsSettingsSuggestionStripOn);
         p.println("  mCorrectionMode=" + mCorrectionMode);
         p.println("  mHasUncommittedTypedChars=" + mHasUncommittedTypedChars);
diff --git a/java/src/com/android/inputmethod/latin/TextEntryState.java b/java/src/com/android/inputmethod/latin/TextEntryState.java
index a6041b3..6b44fc5 100644
--- a/java/src/com/android/inputmethod/latin/TextEntryState.java
+++ b/java/src/com/android/inputmethod/latin/TextEntryState.java
@@ -19,6 +19,7 @@
 import com.android.inputmethod.keyboard.Keyboard;
 import com.android.inputmethod.latin.Utils.RingCharBuffer;
 
+import android.text.TextUtils;
 import android.util.Log;
 
 public class TextEntryState {
@@ -45,7 +46,7 @@
 
     public static void acceptedDefault(CharSequence typedWord, CharSequence actualWord,
             int separatorCode) {
-        if (typedWord == null) return;
+        if (TextUtils.isEmpty(typedWord)) return;
         setState(ACCEPTED_DEFAULT);
         LatinImeLogger.logOnAutoCorrection(
                 typedWord.toString(), actualWord.toString(), separatorCode);
@@ -57,7 +58,7 @@
     // (see "case ACCEPTED_DEFAULT" in typedCharacter() below),
     // and should be restored back to State.ACCEPTED_DEFAULT after processing for each sub-state.
     public static void backToAcceptedDefault(CharSequence typedWord) {
-        if (typedWord == null) return;
+        if (TextUtils.isEmpty(typedWord)) return;
         switch (sState) {
         case SPACE_AFTER_ACCEPTED:
         case PUNCTUATION_AFTER_ACCEPTED:
diff --git a/java/src/com/android/inputmethod/latin/WordComposer.java b/java/src/com/android/inputmethod/latin/WordComposer.java
index 44c89f7..dfb00c8 100644
--- a/java/src/com/android/inputmethod/latin/WordComposer.java
+++ b/java/src/com/android/inputmethod/latin/WordComposer.java
@@ -231,9 +231,6 @@
      * @return the word that was typed so far
      */
     public String getTypedWord() {
-        if (size() == 0) {
-            return null;
-        }
         return mTypedWord.toString();
     }