LatinIME/java/src/com/android/inputmethod/latin/WordComposer.java
Dan Zivkovic 69c04cadc7 Race condition in cursor move.
The method WordComposer.moveCursorByAndReturnIfInsideComposingWord() iterates
through all the code points in the word that's currently being composed, and
it tries to adjust the cursor position by a given amount (left or right).
It copies the code points to a new array while processing. But the code point
count comes from a member variable. If the member variable changes while the
method is processing the copy of the code points, it can run over the length
of the code point array.

Bug 18876474.

Change-Id: Ib3a2d90a4e82b76d381efa774e6b3d6bca99c869
2015-02-27 10:29:47 -08:00

481 lines
19 KiB
Java

/*
* Copyright (C) 2008 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 com.android.inputmethod.latin;
import com.android.inputmethod.annotations.UsedForTesting;
import com.android.inputmethod.event.CombinerChain;
import com.android.inputmethod.event.Event;
import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
import com.android.inputmethod.latin.common.ComposedData;
import com.android.inputmethod.latin.common.Constants;
import com.android.inputmethod.latin.common.CoordinateUtils;
import com.android.inputmethod.latin.common.InputPointers;
import com.android.inputmethod.latin.common.StringUtils;
import com.android.inputmethod.latin.define.DebugFlags;
import com.android.inputmethod.latin.define.DecoderSpecificConstants;
import java.util.ArrayList;
import java.util.Collections;
import javax.annotation.Nonnull;
/**
* A place to store the currently composing word with information such as adjacent key codes as well
*/
public final class WordComposer {
private static final int MAX_WORD_LENGTH = DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH;
private static final boolean DBG = DebugFlags.DEBUG_ENABLED;
public static final int CAPS_MODE_OFF = 0;
// 1 is shift bit, 2 is caps bit, 4 is auto bit but this is just a convention as these bits
// aren't used anywhere in the code
public static final int CAPS_MODE_MANUAL_SHIFTED = 0x1;
public static final int CAPS_MODE_MANUAL_SHIFT_LOCKED = 0x3;
public static final int CAPS_MODE_AUTO_SHIFTED = 0x5;
public static final int CAPS_MODE_AUTO_SHIFT_LOCKED = 0x7;
private CombinerChain mCombinerChain;
private String mCombiningSpec; // Memory so that we don't uselessly recreate the combiner chain
// The list of events that served to compose this string.
private final ArrayList<Event> mEvents;
private final InputPointers mInputPointers = new InputPointers(MAX_WORD_LENGTH);
private SuggestedWordInfo mAutoCorrection;
private boolean mIsResumed;
private boolean mIsBatchMode;
// A memory of the last rejected batch mode suggestion, if any. This goes like this: the user
// gestures a word, is displeased with the results and hits backspace, then gestures again.
// At the very least we should avoid re-suggesting the same thing, and to do that we memorize
// the rejected suggestion in this variable.
// TODO: this should be done in a comprehensive way by the User History feature instead of
// as an ad-hockery here.
private String mRejectedBatchModeSuggestion;
// Cache these values for performance
private CharSequence mTypedWordCache;
private int mCapsCount;
private int mDigitsCount;
private int mCapitalizedMode;
// This is the number of code points entered so far. This is not limited to MAX_WORD_LENGTH.
// In general, this contains the size of mPrimaryKeyCodes, except when this is greater than
// MAX_WORD_LENGTH in which case mPrimaryKeyCodes only contain the first MAX_WORD_LENGTH
// code points.
private int mCodePointSize;
private int mCursorPositionWithinWord;
/**
* Whether the composing word has the only first char capitalized.
*/
private boolean mIsOnlyFirstCharCapitalized;
public WordComposer() {
mCombinerChain = new CombinerChain("");
mEvents = new ArrayList<>();
mAutoCorrection = null;
mIsResumed = false;
mIsBatchMode = false;
mCursorPositionWithinWord = 0;
mRejectedBatchModeSuggestion = null;
refreshTypedWordCache();
}
public ComposedData getComposedDataSnapshot() {
return new ComposedData(getInputPointers(), isBatchMode(), mTypedWordCache.toString());
}
/**
* Restart the combiners, possibly with a new spec.
* @param combiningSpec The spec string for combining. This is found in the extra value.
*/
public void restartCombining(final String combiningSpec) {
final String nonNullCombiningSpec = null == combiningSpec ? "" : combiningSpec;
if (!nonNullCombiningSpec.equals(mCombiningSpec)) {
mCombinerChain = new CombinerChain(
mCombinerChain.getComposingWordWithCombiningFeedback().toString());
mCombiningSpec = nonNullCombiningSpec;
}
}
/**
* Clear out the keys registered so far.
*/
public void reset() {
mCombinerChain.reset();
mEvents.clear();
mAutoCorrection = null;
mCapsCount = 0;
mDigitsCount = 0;
mIsOnlyFirstCharCapitalized = false;
mIsResumed = false;
mIsBatchMode = false;
mCursorPositionWithinWord = 0;
mRejectedBatchModeSuggestion = null;
refreshTypedWordCache();
}
private final void refreshTypedWordCache() {
mTypedWordCache = mCombinerChain.getComposingWordWithCombiningFeedback();
mCodePointSize = Character.codePointCount(mTypedWordCache, 0, mTypedWordCache.length());
}
/**
* Number of keystrokes in the composing word.
* @return the number of keystrokes
*/
public int size() {
return mCodePointSize;
}
public boolean isSingleLetter() {
return size() == 1;
}
public final boolean isComposingWord() {
return size() > 0;
}
public InputPointers getInputPointers() {
return mInputPointers;
}
/**
* Process an event and return an event, and return a processed event to apply.
* @param event the unprocessed event.
* @return the processed event. Never null, but may be marked as consumed.
*/
@Nonnull
public Event processEvent(@Nonnull final Event event) {
final Event processedEvent = mCombinerChain.processEvent(mEvents, event);
// The retained state of the combiner chain may have changed while processing the event,
// so we need to update our cache.
refreshTypedWordCache();
mEvents.add(event);
return processedEvent;
}
/**
* Apply a processed input event.
*
* All input events should be supported, including software/hardware events, characters as well
* as deletions, multiple inputs and gestures.
*
* @param event the event to apply. Must not be null.
*/
public void applyProcessedEvent(final Event event) {
mCombinerChain.applyProcessedEvent(event);
final int primaryCode = event.mCodePoint;
final int keyX = event.mX;
final int keyY = event.mY;
final int newIndex = size();
refreshTypedWordCache();
mCursorPositionWithinWord = mCodePointSize;
// We may have deleted the last one.
if (0 == mCodePointSize) {
mIsOnlyFirstCharCapitalized = false;
}
if (Constants.CODE_DELETE != event.mKeyCode) {
if (newIndex < MAX_WORD_LENGTH) {
// In the batch input mode, the {@code mInputPointers} holds batch input points and
// shouldn't be overridden by the "typed key" coordinates
// (See {@link #setBatchInputWord}).
if (!mIsBatchMode) {
// TODO: Set correct pointer id and time
mInputPointers.addPointerAt(newIndex, keyX, keyY, 0, 0);
}
}
if (0 == newIndex) {
mIsOnlyFirstCharCapitalized = Character.isUpperCase(primaryCode);
} else {
mIsOnlyFirstCharCapitalized = mIsOnlyFirstCharCapitalized
&& !Character.isUpperCase(primaryCode);
}
if (Character.isUpperCase(primaryCode)) mCapsCount++;
if (Character.isDigit(primaryCode)) mDigitsCount++;
}
mAutoCorrection = null;
}
public void setCursorPositionWithinWord(final int posWithinWord) {
mCursorPositionWithinWord = posWithinWord;
// TODO: compute where that puts us inside the events
}
public boolean isCursorFrontOrMiddleOfComposingWord() {
if (DBG && mCursorPositionWithinWord > mCodePointSize) {
throw new RuntimeException("Wrong cursor position : " + mCursorPositionWithinWord
+ "in a word of size " + mCodePointSize);
}
return mCursorPositionWithinWord != mCodePointSize;
}
/**
* When the cursor is moved by the user, we need to update its position.
* If it falls inside the currently composing word, we don't reset the composition, and
* only update the cursor position.
*
* @param expectedMoveAmount How many java chars to move the cursor. Negative values move
* the cursor backward, positive values move the cursor forward.
* @return true if the cursor is still inside the composing word, false otherwise.
*/
public boolean moveCursorByAndReturnIfInsideComposingWord(final int expectedMoveAmount) {
int actualMoveAmount = 0;
int cursorPos = mCursorPositionWithinWord;
// TODO: Don't make that copy. We can do this directly from mTypedWordCache.
final int[] codePoints = StringUtils.toCodePointArray(mTypedWordCache);
if (expectedMoveAmount >= 0) {
// Moving the cursor forward for the expected amount or until the end of the word has
// been reached, whichever comes first.
while (actualMoveAmount < expectedMoveAmount && cursorPos < codePoints.length) {
actualMoveAmount += Character.charCount(codePoints[cursorPos]);
++cursorPos;
}
} else {
// Moving the cursor backward for the expected amount or until the start of the word
// has been reached, whichever comes first.
while (actualMoveAmount > expectedMoveAmount && cursorPos > 0) {
--cursorPos;
actualMoveAmount -= Character.charCount(codePoints[cursorPos]);
}
}
// If the actual and expected amounts differ, we crossed the start or the end of the word
// so the result would not be inside the composing word.
if (actualMoveAmount != expectedMoveAmount) {
return false;
}
mCursorPositionWithinWord = cursorPos;
mCombinerChain.applyProcessedEvent(mCombinerChain.processEvent(
mEvents, Event.createCursorMovedEvent(cursorPos)));
return true;
}
public void setBatchInputPointers(final InputPointers batchPointers) {
mInputPointers.set(batchPointers);
mIsBatchMode = true;
}
public void setBatchInputWord(final String word) {
reset();
mIsBatchMode = true;
final int length = word.length();
for (int i = 0; i < length; i = Character.offsetByCodePoints(word, i, 1)) {
final int codePoint = Character.codePointAt(word, i);
// We don't want to override the batch input points that are held in mInputPointers
// (See {@link #add(int,int,int)}).
final Event processedEvent =
processEvent(Event.createEventForCodePointFromUnknownSource(codePoint));
applyProcessedEvent(processedEvent);
}
}
/**
* Set the currently composing word to the one passed as an argument.
* This will register NOT_A_COORDINATE for X and Ys, and use the passed keyboard for proximity.
* @param codePoints the code points to set as the composing word.
* @param coordinates the x, y coordinates of the key in the CoordinateUtils format
*/
public void setComposingWord(final int[] codePoints, final int[] coordinates) {
reset();
final int length = codePoints.length;
for (int i = 0; i < length; ++i) {
final Event processedEvent =
processEvent(Event.createEventForCodePointFromAlreadyTypedText(codePoints[i],
CoordinateUtils.xFromArray(coordinates, i),
CoordinateUtils.yFromArray(coordinates, i)));
applyProcessedEvent(processedEvent);
}
mIsResumed = true;
}
/**
* Returns the word as it was typed, without any correction applied.
* @return the word that was typed so far. Never returns null.
*/
public String getTypedWord() {
return mTypedWordCache.toString();
}
/**
* Whether this composer is composing or about to compose a word in which only the first letter
* is a capital.
*
* If we do have a composing word, we just return whether the word has indeed only its first
* character capitalized. If we don't, then we return a value based on the capitalized mode,
* which tell us what is likely to happen for the next composing word.
*
* @return capitalization preference
*/
public boolean isOrWillBeOnlyFirstCharCapitalized() {
return isComposingWord() ? mIsOnlyFirstCharCapitalized
: (CAPS_MODE_OFF != mCapitalizedMode);
}
/**
* Whether or not all of the user typed chars are upper case
* @return true if all user typed chars are upper case, false otherwise
*/
public boolean isAllUpperCase() {
if (size() <= 1) {
return mCapitalizedMode == CAPS_MODE_AUTO_SHIFT_LOCKED
|| mCapitalizedMode == CAPS_MODE_MANUAL_SHIFT_LOCKED;
}
return mCapsCount == size();
}
public boolean wasShiftedNoLock() {
return mCapitalizedMode == CAPS_MODE_AUTO_SHIFTED
|| mCapitalizedMode == CAPS_MODE_MANUAL_SHIFTED;
}
/**
* Returns true if more than one character is upper case, otherwise returns false.
*/
public boolean isMostlyCaps() {
return mCapsCount > 1;
}
/**
* Returns true if we have digits in the composing word.
*/
public boolean hasDigits() {
return mDigitsCount > 0;
}
/**
* Saves the caps mode at the start of composing.
*
* WordComposer needs to know about the caps mode for several reasons. The first is, we need
* to know after the fact what the reason was, to register the correct form into the user
* history dictionary: if the word was automatically capitalized, we should insert it in
* all-lower case but if it's a manual pressing of shift, then it should be inserted as is.
* Also, batch input needs to know about the current caps mode to display correctly
* capitalized suggestions.
* @param mode the mode at the time of start
*/
public void setCapitalizedModeAtStartComposingTime(final int mode) {
mCapitalizedMode = mode;
}
/**
* Before fetching suggestions, we don't necessarily know about the capitalized mode yet.
*
* If we don't have a composing word yet, we take a note of this mode so that we can then
* supply this information to the suggestion process. If we have a composing word, then
* the previous mode has priority over this.
* @param mode the mode just before fetching suggestions
*/
public void adviseCapitalizedModeBeforeFetchingSuggestions(final int mode) {
if (!isComposingWord()) {
mCapitalizedMode = mode;
}
}
/**
* Returns whether the word was automatically capitalized.
* @return whether the word was automatically capitalized
*/
public boolean wasAutoCapitalized() {
return mCapitalizedMode == CAPS_MODE_AUTO_SHIFT_LOCKED
|| mCapitalizedMode == CAPS_MODE_AUTO_SHIFTED;
}
/**
* Sets the auto-correction for this word.
*/
public void setAutoCorrection(final SuggestedWordInfo autoCorrection) {
mAutoCorrection = autoCorrection;
}
/**
* @return the auto-correction for this word, or null if none.
*/
public SuggestedWordInfo getAutoCorrectionOrNull() {
return mAutoCorrection;
}
/**
* @return whether we started composing this word by resuming suggestion on an existing string
*/
public boolean isResumed() {
return mIsResumed;
}
// `type' should be one of the LastComposedWord.COMMIT_TYPE_* constants above.
// committedWord should contain suggestion spans if applicable.
public LastComposedWord commitWord(final int type, final CharSequence committedWord,
final String separatorString, final NgramContext ngramContext) {
// Note: currently, we come here whenever we commit a word. If it's a MANUAL_PICK
// or a DECIDED_WORD we may cancel the commit later; otherwise, we should deactivate
// the last composed word to ensure this does not happen.
final LastComposedWord lastComposedWord = new LastComposedWord(mEvents,
mInputPointers, mTypedWordCache.toString(), committedWord, separatorString,
ngramContext, mCapitalizedMode);
mInputPointers.reset();
if (type != LastComposedWord.COMMIT_TYPE_DECIDED_WORD
&& type != LastComposedWord.COMMIT_TYPE_MANUAL_PICK) {
lastComposedWord.deactivate();
}
mCapsCount = 0;
mDigitsCount = 0;
mIsBatchMode = false;
mCombinerChain.reset();
mEvents.clear();
mCodePointSize = 0;
mIsOnlyFirstCharCapitalized = false;
mCapitalizedMode = CAPS_MODE_OFF;
refreshTypedWordCache();
mAutoCorrection = null;
mCursorPositionWithinWord = 0;
mIsResumed = false;
mRejectedBatchModeSuggestion = null;
return lastComposedWord;
}
public void resumeSuggestionOnLastComposedWord(final LastComposedWord lastComposedWord) {
mEvents.clear();
Collections.copy(mEvents, lastComposedWord.mEvents);
mInputPointers.set(lastComposedWord.mInputPointers);
mCombinerChain.reset();
refreshTypedWordCache();
mCapitalizedMode = lastComposedWord.mCapitalizedMode;
mAutoCorrection = null; // This will be filled by the next call to updateSuggestion.
mCursorPositionWithinWord = mCodePointSize;
mRejectedBatchModeSuggestion = null;
mIsResumed = true;
}
public boolean isBatchMode() {
return mIsBatchMode;
}
public void setRejectedBatchModeSuggestion(final String rejectedSuggestion) {
mRejectedBatchModeSuggestion = rejectedSuggestion;
}
public String getRejectedBatchModeSuggestion() {
return mRejectedBatchModeSuggestion;
}
@UsedForTesting
void addInputPointerForTest(int index, int keyX, int keyY) {
mInputPointers.addPointerAt(index, keyX, keyY, 0, 0);
}
@UsedForTesting
void setTypedWordCacheForTests(String typedWordCacheForTests) {
mTypedWordCache = typedWordCacheForTests;
}
}