blob: 89109d73665f75fb71d266b20e6d0fe93ff84b4f [file] [log] [blame]
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.content.browser.input;
import android.annotation.SuppressLint;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.task.PostTask;
import org.chromium.content_public.browser.UiThreadTaskTraits;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
/**
* An implementation of {@link InputConnection} to communicate with external input method
* apps. Note that it is running on IME thread (except for constructor and calls from ImeAdapter)
* such that it does not block UI thread and returns text values immediately after any change
* to them.
* Note that extending {@link BaseInputConnection} is a workaround for some OEM's email client
* which tries to downcast the {@link InputConnection} from {@link View#onCreateInputConnection}
* into {@link BaseInputConnection}. We are implementing every function of {@link InputConnection},
* so 'extends' here should have no functional effect at all. See crbug.com/616334 for more
* details.
*/
class ThreadedInputConnection extends BaseInputConnection implements ChromiumBaseInputConnection {
private static final String TAG = "cr_Ime";
private static final boolean DEBUG_LOGS = false;
private static final TextInputState UNBLOCKER = new TextInputState(
"", new Range(0, 0), new Range(-1, -1), false, false /* notFromIme */) {
@Override
public boolean shouldUnblock() {
return true;
}
};
private final Runnable mProcessPendingInputStatesRunnable = new Runnable() {
@Override
public void run() {
processPendingInputStates();
}
};
private final Runnable mRequestTextInputStateUpdate = new Runnable() {
@Override
public void run() {
boolean result = mImeAdapter.requestTextInputStateUpdate();
if (!result) unblockOnUiThread();
}
};
private final Runnable mNotifyUserActionRunnable = new Runnable() {
@Override
public void run() {
mImeAdapter.notifyUserAction();
}
};
private final Runnable mFinishComposingTextRunnable = new Runnable() {
@Override
public void run() {
finishComposingTextOnUiThread();
}
};
private final ImeAdapterImpl mImeAdapter;
private final Handler mHandler;
private int mNumNestedBatchEdits;
// TODO(changwan): check if we can keep a pool of TextInputState to avoid creating
// a bunch of new objects for each key stroke.
private final BlockingQueue<TextInputState> mQueue = new LinkedBlockingQueue<>();
private int mPendingAccent;
private TextInputState mCachedTextInputState;
private int mCurrentExtractedTextRequestToken;
private boolean mShouldUpdateExtractedText;
ThreadedInputConnection(View view, ImeAdapterImpl imeAdapter, Handler handler) {
super(view, true);
if (DEBUG_LOGS) Log.i(TAG, "constructor");
ImeUtils.checkOnUiThread();
mImeAdapter = imeAdapter;
mHandler = handler;
}
void resetOnUiThread() {
ImeUtils.checkOnUiThread();
mHandler.post(new Runnable() {
@Override
public void run() {
mNumNestedBatchEdits = 0;
mPendingAccent = 0;
mCurrentExtractedTextRequestToken = 0;
mShouldUpdateExtractedText = false;
}
});
}
@Override
public void updateStateOnUiThread(String text, final int selectionStart, final int selectionEnd,
final int compositionStart, final int compositionEnd, boolean singleLine,
final boolean replyToRequest) {
ImeUtils.checkOnUiThread();
mCachedTextInputState = new TextInputState(text, new Range(selectionStart, selectionEnd),
new Range(compositionStart, compositionEnd), singleLine, replyToRequest);
if (DEBUG_LOGS) Log.i(TAG, "updateState: %s", mCachedTextInputState);
addToQueueOnUiThread(mCachedTextInputState);
if (!replyToRequest) {
mHandler.post(mProcessPendingInputStatesRunnable);
}
}
/**
* @see ChromiumBaseInputConnection#getHandler()
*/
@Override
public Handler getHandler() {
return mHandler;
}
/**
* @see ChromiumBaseInputConnection#onRestartInputOnUiThread()
*/
@Override
public void onRestartInputOnUiThread() {}
/**
* @see ChromiumBaseInputConnection#sendKeyEventOnUiThread(KeyEvent)
*/
@Override
public boolean sendKeyEventOnUiThread(final KeyEvent event) {
ImeUtils.checkOnUiThread();
mHandler.post(new Runnable() {
@Override
public void run() {
sendKeyEvent(event);
}
});
return true;
}
@Override
@VisibleForTesting
public void unblockOnUiThread() {
if (DEBUG_LOGS) Log.i(TAG, "unblockOnUiThread");
ImeUtils.checkOnUiThread();
addToQueueOnUiThread(UNBLOCKER);
mHandler.post(mProcessPendingInputStatesRunnable);
}
private void processPendingInputStates() {
if (DEBUG_LOGS) Log.i(TAG, "checkQueue");
assertOnImeThread();
// Handle all the remaining states in the queue.
while (true) {
TextInputState state = mQueue.poll();
if (state == null) {
if (DEBUG_LOGS) Log.i(TAG, "checkQueue - finished");
return;
}
// Unblocker was not used. Ignore.
if (state.shouldUnblock()) {
if (DEBUG_LOGS) Log.i(TAG, "checkQueue - ignoring one unblocker");
continue;
}
if (DEBUG_LOGS) Log.i(TAG, "checkQueue: " + state);
updateSelection(state);
}
}
private void updateSelection(TextInputState textInputState) {
if (textInputState == null) return;
assertOnImeThread();
if (mNumNestedBatchEdits != 0) return;
Range selection = textInputState.selection();
Range composition = textInputState.composition();
// As per Guidelines in
// https://developer.android.com/reference/android/view/inputmethod/InputConnection.html
// #getExtractedText(android.view.inputmethod.ExtractedTextRequest,%20int)
// States that if the GET_EXTRACTED_TEXT_MONITOR flag is set,
// you should be calling updateExtractedText(View, int, ExtractedText)
// whenever you call updateSelection(View, int, int, int, int).
if (mShouldUpdateExtractedText) {
final ExtractedText extractedText = convertToExtractedText(textInputState);
mImeAdapter.updateExtractedText(mCurrentExtractedTextRequestToken, extractedText);
}
mImeAdapter.updateSelection(
selection.start(), selection.end(), composition.start(), composition.end());
}
private TextInputState requestAndWaitForTextInputState() {
if (DEBUG_LOGS) Log.i(TAG, "requestAndWaitForTextInputState");
if (runningOnUiThread()) {
Log.w(TAG, "InputConnection API is not called on IME thread. Returning cached result.");
// Returning cached result is a workaround for existing webview apps. (crbug.com/643477)
return mCachedTextInputState;
}
assertOnImeThread();
PostTask.postTask(UiThreadTaskTraits.DEFAULT, mRequestTextInputStateUpdate);
return blockAndGetStateUpdate();
}
private void addToQueueOnUiThread(TextInputState textInputState) {
ImeUtils.checkOnUiThread();
try {
mQueue.put(textInputState);
} catch (InterruptedException e) {
Log.e(TAG, "addToQueueOnUiThread interrupted", e);
}
if (DEBUG_LOGS) Log.i(TAG, "addToQueueOnUiThread finished: %d", mQueue.size());
}
/**
* @return BlockingQueue for white box unit testing.
*/
BlockingQueue<TextInputState> getQueueForTest() {
return mQueue;
}
@VisibleForTesting
protected boolean runningOnUiThread() {
return ThreadUtils.runningOnUiThread();
}
private void assertOnImeThread() {
ImeUtils.checkCondition(mHandler.getLooper() == Looper.myLooper());
}
/**
* Block until we get the expected state update.
* @return TextInputState if we get it successfully. null otherwise.
*/
private TextInputState blockAndGetStateUpdate() {
if (DEBUG_LOGS) Log.i(TAG, "blockAndGetStateUpdate");
assertOnImeThread();
boolean shouldUpdateSelection = false;
while (true) {
TextInputState state;
try {
state = mQueue.take();
} catch (InterruptedException e) {
// This should never happen since IME thread is artificial and is not exposed
// to other components.
e.printStackTrace();
ImeUtils.checkCondition(false);
return null;
}
if (state.shouldUnblock()) {
if (DEBUG_LOGS) Log.i(TAG, "blockAndGetStateUpdate: unblocked");
return null;
} else if (state.replyToRequest()) {
if (shouldUpdateSelection) updateSelection(state);
if (DEBUG_LOGS) Log.i(TAG, "blockAndGetStateUpdate done: %d", mQueue.size());
return state;
}
// Ignore when state is not from IME, but make sure to update state when we handle
// state from IME.
shouldUpdateSelection = true;
}
}
private void notifyUserAction() {
PostTask.postTask(UiThreadTaskTraits.DEFAULT, mNotifyUserActionRunnable);
}
/**
* @see InputConnection#setComposingText(java.lang.CharSequence, int)
*/
@Override
public boolean setComposingText(final CharSequence text, final int newCursorPosition) {
if (DEBUG_LOGS) Log.i(TAG, "setComposingText [%s] [%d]", text, newCursorPosition);
if (text == null) return false;
return updateComposingText(text, newCursorPosition, false);
}
/**
* Sends composing update to the InputMethodManager.
*/
@VisibleForTesting
public boolean updateComposingText(
final CharSequence text, final int newCursorPosition, final boolean isPendingAccent) {
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
updateComposingTextOnUiThread(text, newCursorPosition, isPendingAccent);
}
});
notifyUserAction();
return true;
}
private void updateComposingTextOnUiThread(
CharSequence text, int newCursorPosition, boolean isPendingAccent) {
int accentToSend =
isPendingAccent ? (mPendingAccent | KeyCharacterMap.COMBINING_ACCENT) : 0;
cancelCombiningAccentOnUiThread();
mImeAdapter.sendCompositionToNative(text, newCursorPosition, false, accentToSend);
}
/**
* @see InputConnection#commitText(java.lang.CharSequence, int)
*/
@Override
public boolean commitText(final CharSequence text, final int newCursorPosition) {
if (DEBUG_LOGS) Log.i(TAG, "commitText [%s] [%d]", text, newCursorPosition);
if (text == null) return false;
// One WebView app detects Enter in JS by looking at KeyDown (http://crbug/577967).
if (TextUtils.equals(text, "\n")) {
beginBatchEdit();
// Clear the current composition range (the keypress alone wouldn't do this).
commitText("", 1);
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
mImeAdapter.sendSyntheticKeyPress(KeyEvent.KEYCODE_ENTER,
KeyEvent.FLAG_SOFT_KEYBOARD | KeyEvent.FLAG_KEEP_TOUCH_MODE);
}
});
endBatchEdit();
return true;
}
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
commitTextOnUiThread(text, newCursorPosition);
}
});
notifyUserAction();
return true;
}
private void commitTextOnUiThread(final CharSequence text, final int newCursorPosition) {
cancelCombiningAccentOnUiThread();
mImeAdapter.sendCompositionToNative(text, newCursorPosition, true, 0);
}
/**
* @see InputConnection#performEditorAction(int)
*/
@Override
public boolean performEditorAction(final int actionCode) {
if (DEBUG_LOGS) Log.i(TAG, "performEditorAction [%d]", actionCode);
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
mImeAdapter.performEditorAction(actionCode);
}
});
return true;
}
/**
* @see InputConnection#performContextMenuAction(int)
*/
@Override
public boolean performContextMenuAction(final int id) {
if (DEBUG_LOGS) Log.i(TAG, "performContextMenuAction [%d]", id);
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
mImeAdapter.performContextMenuAction(id);
}
});
return true;
}
/**
* @see InputConnection#getExtractedText(android.view.inputmethod.ExtractedTextRequest, int)
*/
@Override
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
if (DEBUG_LOGS) Log.i(TAG, "getExtractedText");
assertOnImeThread();
mShouldUpdateExtractedText = (flags & GET_EXTRACTED_TEXT_MONITOR) > 0;
if (mShouldUpdateExtractedText) {
mCurrentExtractedTextRequestToken = request != null ? request.token : 0;
}
TextInputState textInputState = requestAndWaitForTextInputState();
return convertToExtractedText(textInputState);
}
private ExtractedText convertToExtractedText(TextInputState textInputState) {
if (textInputState == null) return null;
ExtractedText extractedText = new ExtractedText();
extractedText.text = textInputState.text();
extractedText.partialEndOffset = textInputState.text().length();
// Set the partial start offset to -1 because the content is the full text.
// See: Android documentation for ExtractedText#partialStartOffset
extractedText.partialStartOffset = -1;
extractedText.selectionStart = textInputState.selection().start();
extractedText.selectionEnd = textInputState.selection().end();
extractedText.flags = textInputState.singleLine() ? ExtractedText.FLAG_SINGLE_LINE : 0;
return extractedText;
}
/**
* @see InputConnection#beginBatchEdit()
*/
@Override
public boolean beginBatchEdit() {
assertOnImeThread();
if (DEBUG_LOGS) Log.i(TAG, "beginBatchEdit [%b]", (mNumNestedBatchEdits == 0));
assertOnImeThread();
mNumNestedBatchEdits++;
return true;
}
/**
* @see InputConnection#endBatchEdit()
*/
@Override
public boolean endBatchEdit() {
assertOnImeThread();
if (mNumNestedBatchEdits == 0) return false;
--mNumNestedBatchEdits;
if (DEBUG_LOGS) Log.i(TAG, "endBatchEdit [%b]", (mNumNestedBatchEdits == 0));
if (mNumNestedBatchEdits == 0) {
updateSelection(requestAndWaitForTextInputState());
}
return mNumNestedBatchEdits != 0;
}
/**
* @see InputConnection#deleteSurroundingText(int, int)
*/
@Override
public boolean deleteSurroundingText(final int beforeLength, final int afterLength) {
if (DEBUG_LOGS) Log.i(TAG, "deleteSurroundingText [%d %d]", beforeLength, afterLength);
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
if (mPendingAccent != 0) {
finishComposingTextOnUiThread();
}
mImeAdapter.deleteSurroundingText(beforeLength, afterLength);
}
});
return true;
}
/**
* @see InputConnection#deleteSurroundingTextInCodePoints(int, int)
*/
@Override
public boolean deleteSurroundingTextInCodePoints(
final int beforeLength, final int afterLength) {
if (DEBUG_LOGS) {
Log.i(TAG, "deleteSurroundingTextInCodePoints [%d %d]", beforeLength, afterLength);
}
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
if (mPendingAccent != 0) {
finishComposingTextOnUiThread();
}
mImeAdapter.deleteSurroundingTextInCodePoints(beforeLength, afterLength);
}
});
return true;
}
/**
* @see InputConnection#sendKeyEvent(android.view.KeyEvent)
*/
@Override
public boolean sendKeyEvent(final KeyEvent event) {
if (DEBUG_LOGS) Log.i(TAG, "sendKeyEvent [%d %d]", event.getAction(), event.getKeyCode());
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
if (handleCombiningAccentOnUiThread(event)) return;
mImeAdapter.sendKeyEvent(event);
}
});
notifyUserAction();
return true;
}
private void commitCodePointOnUiThread(int codePoint, int pendingAccentToSet) {
StringBuilder builder = new StringBuilder();
builder.appendCodePoint(codePoint);
String text = builder.toString();
mImeAdapter.sendCompositionToNative(text, 1, true, 0);
setCombiningAccentOnUiThread(pendingAccentToSet);
}
private boolean handleCombiningAccentOnUiThread(final KeyEvent event) {
// TODO(changwan): this will break the current composition. check if we can
// implement it in the renderer instead.
int action = event.getAction();
int unicodeChar = event.getUnicodeChar();
if (action != KeyEvent.ACTION_DOWN) return false;
if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) {
// We clear the pending accent on receiving a backspace key event (and also delete the
// preceding character).
setCombiningAccentOnUiThread(0);
return false;
}
if ((unicodeChar & KeyCharacterMap.COMBINING_ACCENT) != 0) {
int newPendingAccent = unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK;
if (mPendingAccent != 0) {
// Already have an accent pending. Commit the previous accent. If the newly-typed
// accent is not the same as the previous one, set it as pending.
if (newPendingAccent == mPendingAccent) {
commitCodePointOnUiThread(mPendingAccent, 0);
} else {
commitCodePointOnUiThread(mPendingAccent, newPendingAccent);
}
return true;
}
// No accent currently pending. Just set the new accent as the pending accent and
// return.
setCombiningAccentOnUiThread(newPendingAccent);
return true;
} else if (mPendingAccent != 0 && unicodeChar != 0) {
int combined = KeyEvent.getDeadChar(mPendingAccent, unicodeChar);
if (combined != 0) {
commitCodePointOnUiThread(combined, 0);
return true;
}
// Noncombinable character; commit the accent character and fall through to sending
// the key event for the character afterwards.
commitCodePointOnUiThread(mPendingAccent, 0);
finishComposingTextOnUiThread();
}
return false;
}
@VisibleForTesting
public void setCombiningAccentOnUiThread(int pendingAccent) {
mPendingAccent = pendingAccent;
}
private void cancelCombiningAccentOnUiThread() {
mPendingAccent = 0;
}
/**
* @see InputConnection#finishComposingText()
*/
@Override
public boolean finishComposingText() {
if (DEBUG_LOGS) Log.i(TAG, "finishComposingText");
// This is the only function that may be called on UI thread because
// of direct calls from InputMethodManager.
PostTask.postTask(UiThreadTaskTraits.DEFAULT, mFinishComposingTextRunnable);
return true;
}
private void finishComposingTextOnUiThread() {
mImeAdapter.finishComposingText();
}
/**
* @see InputConnection#setSelection(int, int)
*/
@Override
public boolean setSelection(final int start, final int end) {
if (DEBUG_LOGS) Log.i(TAG, "setSelection [%d %d]", start, end);
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
mImeAdapter.setEditableSelectionOffsets(start, end);
}
});
return true;
}
/**
* @see InputConnection#setComposingRegion(int, int)
*/
@Override
public boolean setComposingRegion(final int start, final int end) {
if (DEBUG_LOGS) Log.i(TAG, "setComposingRegion [%d %d]", start, end);
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
mImeAdapter.setComposingRegion(start, end);
}
});
return true;
}
/**
* @see InputConnection#getTextBeforeCursor(int, int)
*/
@Override
public CharSequence getTextBeforeCursor(int maxChars, int flags) {
if (DEBUG_LOGS) Log.i(TAG, "getTextBeforeCursor [%d %x]", maxChars, flags);
TextInputState textInputState = requestAndWaitForTextInputState();
if (textInputState == null) return null;
return textInputState.getTextBeforeSelection(maxChars);
}
/**
* @see InputConnection#getTextAfterCursor(int, int)
*/
@Override
public CharSequence getTextAfterCursor(int maxChars, int flags) {
if (DEBUG_LOGS) Log.i(TAG, "getTextAfterCursor [%d %x]", maxChars, flags);
TextInputState textInputState = requestAndWaitForTextInputState();
if (textInputState == null) return null;
return textInputState.getTextAfterSelection(maxChars);
}
/**
* @see InputConnection#getSelectedText(int)
*/
@Override
public CharSequence getSelectedText(int flags) {
if (DEBUG_LOGS) Log.i(TAG, "getSelectedText [%x]", flags);
TextInputState textInputState = requestAndWaitForTextInputState();
if (textInputState == null) return null;
return textInputState.getSelectedText();
}
/**
* @see InputConnection#getCursorCapsMode(int)
*/
@Override
public int getCursorCapsMode(int reqModes) {
TextInputState textInputState = requestAndWaitForTextInputState();
int result = 0;
if (textInputState != null) {
result = TextUtils.getCapsMode(
textInputState.text(), textInputState.selection().start(), reqModes);
}
if (DEBUG_LOGS) Log.i(TAG, "getCursorCapsMode [%x]: %x", reqModes, result);
return result;
}
/**
* @see InputConnection#commitCompletion(android.view.inputmethod.CompletionInfo)
*/
@Override
public boolean commitCompletion(CompletionInfo text) {
if (DEBUG_LOGS) Log.i(TAG, "commitCompletion [%s]", text);
return false;
}
/**
* @see InputConnection#commitCorrection(android.view.inputmethod.CorrectionInfo)
*/
@Override
public boolean commitCorrection(CorrectionInfo correctionInfo) {
if (DEBUG_LOGS) {
Log.i(TAG, "commitCorrection [%s]",
ImeUtils.getCorrectionInfoDebugString(correctionInfo));
}
return false;
}
/**
* @see InputConnection#clearMetaKeyStates(int)
*/
@Override
public boolean clearMetaKeyStates(int states) {
if (DEBUG_LOGS) Log.i(TAG, "clearMetaKeyStates [%x]", states);
return false;
}
/**
* @see InputConnection#reportFullscreenMode(boolean)
*/
@Override
public boolean reportFullscreenMode(boolean enabled) {
if (DEBUG_LOGS) Log.i(TAG, "reportFullscreenMode [%b]", enabled);
// We ignore fullscreen mode for now. That's why we set
// EditorInfo.IME_FLAG_NO_FULLSCREEN in constructor.
// Note that this may be called on UI thread.
return false;
}
/**
* @see InputConnection#performPrivateCommand(java.lang.String, android.os.Bundle)
*/
@Override
public boolean performPrivateCommand(String action, Bundle data) {
if (DEBUG_LOGS) Log.i(TAG, "performPrivateCommand [%s]", action);
return false;
}
/**
* @see InputConnection#requestCursorUpdates(int)
*/
@Override
public boolean requestCursorUpdates(final int cursorUpdateMode) {
if (DEBUG_LOGS) Log.i(TAG, "requestCursorUpdates [%x]", cursorUpdateMode);
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
mImeAdapter.onRequestCursorUpdates(cursorUpdateMode);
}
});
return true;
}
/**
* @see InputConnection#closeConnection()
*/
// TODO(crbug.com/635567): Fix this properly.
@Override
@SuppressLint("MissingSuperCall")
public void closeConnection() {
if (DEBUG_LOGS) Log.i(TAG, "closeConnection");
// TODO(changwan): Implement this. http://crbug.com/595525
}
}