summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java')
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java2616
1 files changed, 2616 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
new file mode 100644
index 0000000000..2d24dcbe93
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
@@ -0,0 +1,2616 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.RectF;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.SystemClock;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.method.KeyListener;
+import android.text.method.TextKeyListener;
+import android.text.style.CharacterStyle;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.mozilla.gecko.GeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.ThreadUtils.AssertBehavior;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEContextFlags;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMENotificationType;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEState;
+
+/**
+ * GeckoEditable implements only some functions of Editable The field mText contains the actual
+ * underlying SpannableStringBuilder/Editable that contains our text.
+ */
+/* package */ final class GeckoEditable extends IGeckoEditableParent.Stub
+ implements InvocationHandler, Editable, SessionTextInput.EditableClient {
+
+ private static final boolean DEBUG = false;
+ private static final String LOGTAG = "GeckoEditable";
+
+ // Filters to implement Editable's filtering functionality
+ private InputFilter[] mFilters;
+
+ /**
+ * We need a WeakReference here to avoid unnecessary retention of the GeckoSession. Passing
+ * objects around via JNI seems to confuse the GC into thinking we have a native GC root.
+ */
+ /* package */ final WeakReference<GeckoSession> mSession;
+
+ private final AsyncText mText;
+ private final Editable mProxy;
+ private final ConcurrentLinkedQueue<Action> mActions;
+ private KeyCharacterMap mKeyMap;
+
+ // mIcRunHandler is the Handler that currently runs Gecko-to-IC Runnables
+ // mIcPostHandler is the Handler to post Gecko-to-IC Runnables to
+ // The two can be different when switching from one handler to another
+ private Handler mIcRunHandler;
+ private Handler mIcPostHandler;
+
+ // Parent process child used as a default for key events.
+ /* package */ IGeckoEditableChild mDefaultChild; // Used by IC thread.
+ // Parent or content process child that has the focus.
+ /* package */ IGeckoEditableChild mFocusedChild; // Used by IC thread.
+ /* package */ IBinder mFocusedToken; // Used by Gecko/binder thread.
+ /* package */ SessionTextInput.EditableListener mListener;
+
+ /* package */ boolean mInBatchMode; // Used by IC thread
+ /* package */ boolean mNeedSync; // Used by IC thread
+ // Gecko side needs an updated composition from Java;
+ private boolean mNeedUpdateComposition; // Used by IC thread
+ private boolean mSuppressKeyUp; // Used by IC thread
+
+ @IMEState
+ private int mIMEState = // Used by IC thread.
+ SessionTextInput.EditableListener.IME_STATE_DISABLED;
+
+ private String mIMETypeHint = ""; // Used by IC/UI thread.
+ private String mIMEModeHint = ""; // Used by IC thread.
+ private String mIMEActionHint = ""; // Used by IC thread.
+ private String mIMEAutocapitalize = ""; // Used by IC thread.
+ @IMEContextFlags private int mIMEFlags; // Used by IC thread.
+
+ private boolean mIgnoreSelectionChange; // Used by Gecko thread
+ // Combined offsets from the previous batch of onTextChange calls; valid
+ // between the onTextChange calls and the next onSelectionChange call.
+ private int mLastTextChangeStart = Integer.MAX_VALUE; // Used by Gecko thread
+ private int mLastTextChangeOldEnd = -1; // Used by Gecko thread
+ private int mLastTextChangeNewEnd = -1; // Used by Gecko thread
+ private boolean mLastTextChangeReplacedSelection; // Used by Gecko thread
+
+ // Prevent showSoftInput and hideSoftInput from being called multiple times in a row,
+ // including reentrant calls on some devices. Used by UI/IC thread.
+ /* package */ final AtomicInteger mSoftInputReentrancyGuard = new AtomicInteger();
+
+ private static final int IME_RANGE_CARETPOSITION = 1;
+ private static final int IME_RANGE_RAWINPUT = 2;
+ private static final int IME_RANGE_SELECTEDRAWTEXT = 3;
+ private static final int IME_RANGE_CONVERTEDTEXT = 4;
+ private static final int IME_RANGE_SELECTEDCONVERTEDTEXT = 5;
+
+ private static final int IME_RANGE_LINE_NONE = 0;
+ private static final int IME_RANGE_LINE_SOLID = 1;
+ private static final int IME_RANGE_LINE_DOTTED = 2;
+ private static final int IME_RANGE_LINE_DASHED = 3;
+ private static final int IME_RANGE_LINE_DOUBLE = 4;
+ private static final int IME_RANGE_LINE_WAVY = 5;
+
+ private static final int IME_RANGE_UNDERLINE = 1;
+ private static final int IME_RANGE_FORECOLOR = 2;
+ private static final int IME_RANGE_BACKCOLOR = 4;
+ private static final int IME_RANGE_LINECOLOR = 8;
+
+ private void onKeyEvent(
+ final IGeckoEditableChild child,
+ final KeyEvent event,
+ final int action,
+ final int savedMetaState,
+ final boolean isSynthesizedImeKey)
+ throws RemoteException {
+ // Use a separate action argument so we can override the key's original action,
+ // e.g. change ACTION_MULTIPLE to ACTION_DOWN. That way we don't have to allocate
+ // a new key event just to change its action field.
+ //
+ // Normally we expect event.getMetaState() to reflect the current meta-state; however,
+ // some software-generated key events may not have event.getMetaState() set, e.g. key
+ // events from Swype. Therefore, it's necessary to combine the key's meta-states
+ // with the meta-states that we keep separately in KeyListener
+ final int metaState = event.getMetaState() | savedMetaState;
+ final int unmodifiedMetaState =
+ metaState & ~(KeyEvent.META_ALT_MASK | KeyEvent.META_CTRL_MASK | KeyEvent.META_META_MASK);
+
+ final int unicodeChar = event.getUnicodeChar(metaState);
+ final int unmodifiedUnicodeChar = event.getUnicodeChar(unmodifiedMetaState);
+ final int domPrintableKeyValue =
+ unicodeChar >= ' '
+ ? unicodeChar
+ : unmodifiedMetaState != metaState ? unmodifiedUnicodeChar : 0;
+
+ // If a modifier (e.g. meta key) caused a different character to be entered, we
+ // drop that modifier from the metastate for the generated keypress event.
+ final int keyPressMetaState =
+ (unicodeChar >= ' ' && unicodeChar != unmodifiedUnicodeChar)
+ ? unmodifiedMetaState
+ : metaState;
+
+ // For synthesized keys, ignore modifier metastates from the synthesized event,
+ // because the synthesized modifier metastates don't reflect the actual state of
+ // the meta keys (bug 1387889). For example, the Latin sharp S (U+00DF) is
+ // synthesized as Alt+S, but we don't want the Alt metastate because the Alt key
+ // is not actually pressed in this case.
+ final int keyUpDownMetaState =
+ isSynthesizedImeKey ? (unmodifiedMetaState | savedMetaState) : metaState;
+
+ child.onKeyEvent(
+ action,
+ event.getKeyCode(),
+ event.getScanCode(),
+ keyUpDownMetaState,
+ keyPressMetaState,
+ event.getEventTime(),
+ domPrintableKeyValue,
+ event.getRepeatCount(),
+ event.getFlags(),
+ isSynthesizedImeKey,
+ event);
+ }
+
+ /**
+ * Class that encapsulates asynchronous text editing. There are two copies of the text, a current
+ * copy and a shadow copy. Both can be modified independently through the current*** and shadow***
+ * methods, respectively. The current copy can only be modified on the Gecko side and reflects the
+ * authoritative version of the text. The shadow copy can only be modified on the IC side and
+ * reflects what we think the current text is. Periodically, the shadow copy can be synced to the
+ * current copy through syncShadowText, so the shadow copy once again refers to the same text as
+ * the current copy.
+ */
+ private final class AsyncText {
+ // The current text is the update-to-date version of the text, and is only updated
+ // on the Gecko side.
+ private final SpannableStringBuilder mCurrentText = new SpannableStringBuilder();
+ // Track changes on the current side for syncing purposes.
+ // Start of the changed range in current text since last sync.
+ private int mCurrentStart = Integer.MAX_VALUE;
+ // End of the changed range (before the change) in current text since last sync.
+ private int mCurrentOldEnd;
+ // End of the changed range (after the change) in current text since last sync.
+ private int mCurrentNewEnd;
+ // Track selection changes separately.
+ private boolean mCurrentSelectionChanged;
+
+ // The shadow text is what we think the current text is on the Java side, and is
+ // periodically synced with the current text.
+ private final SpannableStringBuilder mShadowText = new SpannableStringBuilder();
+ // Track changes on the shadow side for syncing purposes.
+ // Start of the changed range in shadow text since last sync.
+ private int mShadowStart = Integer.MAX_VALUE;
+ // End of the changed range (before the change) in shadow text since last sync.
+ private int mShadowOldEnd;
+ // End of the changed range (after the change) in shadow text since last sync.
+ private int mShadowNewEnd;
+
+ private void addCurrentChangeLocked(final int start, final int oldEnd, final int newEnd) {
+ // Merge the new change into any existing change.
+ mCurrentStart = Math.min(mCurrentStart, start);
+ mCurrentOldEnd += Math.max(0, oldEnd - mCurrentNewEnd);
+ mCurrentNewEnd = newEnd + Math.max(0, mCurrentNewEnd - oldEnd);
+ }
+
+ public synchronized void currentReplace(
+ final int start, final int end, final CharSequence newText) {
+ // On Gecko or binder thread.
+ mCurrentText.replace(start, end, newText);
+ addCurrentChangeLocked(start, end, start + newText.length());
+ }
+
+ public synchronized void currentSetSelection(final int start, final int end) {
+ // On Gecko or binder thread.
+ Selection.setSelection(mCurrentText, start, end);
+ mCurrentSelectionChanged = true;
+ }
+
+ public synchronized void currentSetSpan(
+ final Object obj, final int start, final int end, final int flags) {
+ // On Gecko or binder thread.
+ mCurrentText.setSpan(obj, start, end, flags);
+ addCurrentChangeLocked(start, end, end);
+ }
+
+ public synchronized void currentRemoveSpan(final Object obj) {
+ // On Gecko or binder thread.
+ if (obj == null) {
+ mCurrentText.clearSpans();
+ addCurrentChangeLocked(0, mCurrentText.length(), mCurrentText.length());
+ return;
+ }
+ final int start = mCurrentText.getSpanStart(obj);
+ final int end = mCurrentText.getSpanEnd(obj);
+ if (start < 0 || end < 0) {
+ return;
+ }
+ mCurrentText.removeSpan(obj);
+ addCurrentChangeLocked(start, end, end);
+ }
+
+ // Return Spanned instead of Editable because the returned object is supposed to
+ // be read-only. Editing should be done through one of the current*** methods.
+ public Spanned getCurrentText() {
+ // On Gecko or binder thread.
+ return mCurrentText;
+ }
+
+ private void addShadowChange(final int start, final int oldEnd, final int newEnd) {
+ // Merge the new change into any existing change.
+ mShadowStart = Math.min(mShadowStart, start);
+ mShadowOldEnd += Math.max(0, oldEnd - mShadowNewEnd);
+ mShadowNewEnd = newEnd + Math.max(0, mShadowNewEnd - oldEnd);
+ }
+
+ public void shadowReplace(final int start, final int end, final CharSequence newText) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ mShadowText.replace(start, end, newText);
+ addShadowChange(start, end, start + newText.length());
+ }
+
+ public void shadowSetSpan(final Object obj, final int start, final int end, final int flags) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ mShadowText.setSpan(obj, start, end, flags);
+ addShadowChange(start, end, end);
+ }
+
+ public void shadowRemoveSpan(final Object obj) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ if (obj == null) {
+ mShadowText.clearSpans();
+ addShadowChange(0, mShadowText.length(), mShadowText.length());
+ return;
+ }
+ final int start = mShadowText.getSpanStart(obj);
+ final int end = mShadowText.getSpanEnd(obj);
+ if (start < 0 || end < 0) {
+ return;
+ }
+ mShadowText.removeSpan(obj);
+ addShadowChange(start, end, end);
+ }
+
+ // Return Spanned instead of Editable because the returned object is supposed to
+ // be read-only. Editing should be done through one of the shadow*** methods.
+ public Spanned getShadowText() {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ return mShadowText;
+ }
+
+ /**
+ * Check whether we are currently discarding the composition. It means that shadow text has
+ * composition, but current text has no composition. So syncShadowText will discard composition.
+ *
+ * @return true if discarding composition
+ */
+ private boolean isDiscardingComposition() {
+ if (!isComposing(mShadowText)) {
+ return false;
+ }
+
+ return !isComposing(mCurrentText);
+ }
+
+ public synchronized void syncShadowText(final SessionTextInput.EditableListener listener) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ if (mCurrentStart > mCurrentOldEnd && mShadowStart > mShadowOldEnd) {
+ // Still check selection changes.
+ if (!mCurrentSelectionChanged) {
+ return;
+ }
+ final int start = Selection.getSelectionStart(mCurrentText);
+ final int end = Selection.getSelectionEnd(mCurrentText);
+ Selection.setSelection(mShadowText, start, end);
+ mCurrentSelectionChanged = false;
+
+ if (listener != null) {
+ listener.onSelectionChange();
+ }
+ return;
+ }
+
+ if (isDiscardingComposition()) {
+ if (listener != null) {
+ listener.onDiscardComposition();
+ }
+ }
+
+ // Copy the portion of the current text that has changed over to the shadow
+ // text, with consideration for any concurrent changes in the shadow text.
+ final int start = Math.min(mShadowStart, mCurrentStart);
+ final int shadowEnd = mShadowNewEnd + Math.max(0, mCurrentOldEnd - mShadowOldEnd);
+ final int currentEnd = mCurrentNewEnd + Math.max(0, mShadowOldEnd - mCurrentOldEnd);
+
+ // Remove existing spans that may no longer be in the new text.
+ Object[] spans = mShadowText.getSpans(start, shadowEnd, Object.class);
+ for (final Object span : spans) {
+ mShadowText.removeSpan(span);
+ }
+
+ mShadowText.replace(start, shadowEnd, mCurrentText, start, currentEnd);
+
+ // The replace() call may not have copied all affected spans, so we re-copy all the
+ // spans manually just in case. Expand bounds by 1 so we get all the spans.
+ spans =
+ mCurrentText.getSpans(
+ Math.max(start - 1, 0),
+ Math.min(currentEnd + 1, mCurrentText.length()),
+ Object.class);
+ for (final Object span : spans) {
+ if (span == Selection.SELECTION_START || span == Selection.SELECTION_END) {
+ continue;
+ }
+ mShadowText.setSpan(
+ span,
+ mCurrentText.getSpanStart(span),
+ mCurrentText.getSpanEnd(span),
+ mCurrentText.getSpanFlags(span));
+ }
+
+ // SpannableStringBuilder has some internal logic to fix up selections, but we
+ // don't want that, so we always fix up the selection a second time.
+ final int selStart = Selection.getSelectionStart(mCurrentText);
+ final int selEnd = Selection.getSelectionEnd(mCurrentText);
+ Selection.setSelection(mShadowText, selStart, selEnd);
+
+ if (DEBUG && !checkEqualText(mShadowText, mCurrentText)) {
+ // Sanity check.
+ throw new IllegalStateException(
+ "Failed to sync: "
+ + mShadowStart
+ + '-'
+ + mShadowOldEnd
+ + '-'
+ + mShadowNewEnd
+ + '/'
+ + mCurrentStart
+ + '-'
+ + mCurrentOldEnd
+ + '-'
+ + mCurrentNewEnd);
+ }
+
+ if (listener != null) {
+ // Call onTextChange after selection fix-up but before we call
+ // onSelectionChange.
+ listener.onTextChange();
+
+ if (mCurrentSelectionChanged
+ || (mCurrentOldEnd != mCurrentNewEnd
+ && (selStart >= mCurrentStart || selEnd >= mCurrentStart))) {
+ listener.onSelectionChange();
+ }
+ }
+
+ // These values ensure the first change is properly added.
+ mCurrentStart = mShadowStart = Integer.MAX_VALUE;
+ mCurrentOldEnd = mShadowOldEnd = 0;
+ mCurrentNewEnd = mShadowNewEnd = 0;
+ mCurrentSelectionChanged = false;
+ }
+ }
+
+ private static boolean checkEqualText(final Spanned s1, final Spanned s2) {
+ if (!s1.toString().equals(s2.toString())) {
+ return false;
+ }
+
+ final Object[] o1s = s1.getSpans(0, s1.length(), Object.class);
+ final Object[] o2s = s2.getSpans(0, s2.length(), Object.class);
+
+ if (o1s.length != o2s.length) {
+ return false;
+ }
+
+ o1loop:
+ for (final Object o1 : o1s) {
+ for (final Object o2 : o2s) {
+ if (o1 != o2) {
+ continue;
+ }
+ if (s1.getSpanStart(o1) != s2.getSpanStart(o2)
+ || s1.getSpanEnd(o1) != s2.getSpanEnd(o2)
+ || s1.getSpanFlags(o1) != s2.getSpanFlags(o2)) {
+ return false;
+ }
+ continue o1loop;
+ }
+ // o1 not found in o2s.
+ return false;
+ }
+ return true;
+ }
+
+ /* An action that alters the Editable
+
+ Each action corresponds to a Gecko event. While the Gecko event is being sent to the Gecko
+ thread, the action stays on top of mActions queue. After the Gecko event is processed and
+ replied, the action is removed from the queue
+ */
+ private static final class Action {
+ // For input events (keypress, etc.); use with onImeSynchronize
+ static final int TYPE_EVENT = 0;
+ // For Editable.replace() call; use with onImeReplaceText
+ static final int TYPE_REPLACE_TEXT = 1;
+ // For Editable.setSpan() call; use with onImeSynchronize
+ static final int TYPE_SET_SPAN = 2;
+ // For Editable.removeSpan() call; use with onImeSynchronize
+ static final int TYPE_REMOVE_SPAN = 3;
+ // For switching handler; use with onImeSynchronize
+ static final int TYPE_SET_HANDLER = 4;
+
+ final int mType;
+ int mStart;
+ int mEnd;
+ CharSequence mSequence;
+ Object mSpanObject;
+ int mSpanFlags;
+ Handler mHandler;
+
+ Action(final int type) {
+ mType = type;
+ }
+
+ static Action newReplaceText(final CharSequence text, final int start, final int end) {
+ if (start < 0 || start > end) {
+ Log.e(LOGTAG, "invalid replace text offsets: " + start + " to " + end);
+ throw new IllegalArgumentException("invalid replace text offsets");
+ }
+
+ final Action action = new Action(TYPE_REPLACE_TEXT);
+ action.mSequence = text;
+ action.mStart = start;
+ action.mEnd = end;
+ return action;
+ }
+
+ static Action newSetSpan(final Object object, final int start, final int end, final int flags) {
+ if (start < 0 || start > end) {
+ Log.e(LOGTAG, "invalid span offsets: " + start + " to " + end);
+ throw new IllegalArgumentException("invalid span offsets");
+ }
+ final Action action = new Action(TYPE_SET_SPAN);
+ action.mSpanObject = object;
+ action.mStart = start;
+ action.mEnd = end;
+ action.mSpanFlags = flags;
+ return action;
+ }
+
+ static Action newRemoveSpan(final Object object) {
+ final Action action = new Action(TYPE_REMOVE_SPAN);
+ action.mSpanObject = object;
+ return action;
+ }
+
+ static Action newSetHandler(final Handler handler) {
+ final Action action = new Action(TYPE_SET_HANDLER);
+ action.mHandler = handler;
+ return action;
+ }
+ }
+
+ private void icOfferAction(final Action action) {
+ if (DEBUG) {
+ assertOnIcThread();
+ Log.d(LOGTAG, "offer: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")");
+ }
+
+ switch (action.mType) {
+ case Action.TYPE_EVENT:
+ case Action.TYPE_SET_HANDLER:
+ break;
+
+ case Action.TYPE_SET_SPAN:
+ mText.shadowSetSpan(
+ action.mSpanObject, action.mStart,
+ action.mEnd, action.mSpanFlags);
+ break;
+
+ case Action.TYPE_REMOVE_SPAN:
+ action.mSpanFlags = mText.getShadowText().getSpanFlags(action.mSpanObject);
+ mText.shadowRemoveSpan(action.mSpanObject);
+ break;
+
+ case Action.TYPE_REPLACE_TEXT:
+ mText.shadowReplace(action.mStart, action.mEnd, action.mSequence);
+ break;
+
+ default:
+ throw new IllegalStateException("Action not processed");
+ }
+
+ // Always perform actions on the shadow text side above, so we still act as a
+ // valid Editable object, but don't send the actions to Gecko below if we haven't
+ // been focused or initialized, or we've been destroyed.
+ if (mFocusedChild == null || mListener == null) {
+ return;
+ }
+
+ mActions.offer(action);
+
+ try {
+ icPerformAction(action);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ // Undo the offer.
+ mActions.remove(action);
+ }
+ }
+
+ private void icPerformAction(final Action action) throws RemoteException {
+ switch (action.mType) {
+ case Action.TYPE_EVENT:
+ case Action.TYPE_SET_HANDLER:
+ mFocusedChild.onImeSynchronize();
+ break;
+
+ case Action.TYPE_SET_SPAN:
+ {
+ final boolean needUpdate =
+ (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0
+ && ((action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0
+ || action.mSpanObject == Selection.SELECTION_START
+ || action.mSpanObject == Selection.SELECTION_END);
+
+ action.mSequence = TextUtils.substring(mText.getShadowText(), action.mStart, action.mEnd);
+
+ mNeedUpdateComposition |= needUpdate;
+ if (needUpdate) {
+ icMaybeSendComposition(
+ mText.getShadowText(),
+ SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT);
+ }
+
+ mFocusedChild.onImeSynchronize();
+ break;
+ }
+ case Action.TYPE_REMOVE_SPAN:
+ {
+ final boolean needUpdate =
+ (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0
+ && (action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0;
+
+ mNeedUpdateComposition |= needUpdate;
+ if (needUpdate) {
+ icMaybeSendComposition(
+ mText.getShadowText(),
+ SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT);
+ }
+
+ mFocusedChild.onImeSynchronize();
+ break;
+ }
+ case Action.TYPE_REPLACE_TEXT:
+ // Always sync text after a replace action, so that if the Gecko
+ // text is not changed, we will revert the shadow text to before.
+ mNeedSync = true;
+
+ // Because we get composition styling here essentially for free,
+ // we don't need to check if we're in batch mode.
+ if (icMaybeSendComposition(action.mSequence, SEND_COMPOSITION_USE_ENTIRE_TEXT)) {
+ mFocusedChild.onImeReplaceText(action.mStart, action.mEnd, action.mSequence.toString());
+ break;
+ }
+
+ // Since we don't have a composition, we can try sending key events.
+ sendCharKeyEvents(action);
+
+ // onImeReplaceText will set the selection range. But we don't
+ // know whether event state manager is processing text and
+ // selection. So current shadow may not be synchronized with
+ // Gecko's text and selection. So we have to avoid unnecessary
+ // selection update.
+ final int selStartOnShadow = Selection.getSelectionStart(mText.getShadowText());
+ final int selEndOnShadow = Selection.getSelectionEnd(mText.getShadowText());
+ int actionStart = action.mStart;
+ int actionEnd = action.mEnd;
+ // If action range is collapsed and selection of shadow text is
+ // collapsed, we may try to dispatch keypress on current caret
+ // position. Action range is previous range before dispatching
+ // keypress, and shadow range is new range after dispatching
+ // it.
+ if (action.mStart == action.mEnd
+ && selStartOnShadow == selEndOnShadow
+ && action.mStart == selStartOnShadow + action.mSequence.toString().length()) {
+ // Replacing range is same value as current shadow's selection.
+ // So it is unnecessary to update the selection on Gecko.
+ actionStart = -1;
+ actionEnd = -1;
+ }
+ mFocusedChild.onImeReplaceText(actionStart, actionEnd, action.mSequence.toString());
+ break;
+
+ default:
+ throw new IllegalStateException("Action not processed");
+ }
+ }
+
+ private KeyEvent[] synthesizeKeyEvents(final CharSequence cs) {
+ try {
+ if (mKeyMap == null) {
+ mKeyMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
+ }
+ } catch (final Exception e) {
+ // KeyCharacterMap.UnavailableException is not found on Gingerbread;
+ // besides, it seems like HC and ICS will throw something other than
+ // KeyCharacterMap.UnavailableException; so use a generic Exception here
+ return null;
+ }
+ final KeyEvent[] keyEvents = mKeyMap.getEvents(cs.toString().toCharArray());
+ if (keyEvents == null || keyEvents.length == 0) {
+ return null;
+ }
+ return keyEvents;
+ }
+
+ private void sendCharKeyEvents(final Action action) throws RemoteException {
+ if (action.mSequence.length() != 1
+ || (action.mSequence instanceof Spannable
+ && ((Spannable) action.mSequence).nextSpanTransition(-1, Integer.MAX_VALUE, null)
+ < Integer.MAX_VALUE)) {
+ // Spans are not preserved when we use key events,
+ // so we need the sequence to not have any spans
+ return;
+ }
+ final KeyEvent[] keyEvents = synthesizeKeyEvents(action.mSequence);
+ if (keyEvents == null) {
+ return;
+ }
+ for (final KeyEvent event : keyEvents) {
+ if (KeyEvent.isModifierKey(event.getKeyCode())) {
+ continue;
+ }
+ if (event.getAction() == KeyEvent.ACTION_UP && mSuppressKeyUp) {
+ continue;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "sending: " + event);
+ }
+ onKeyEvent(
+ mFocusedChild,
+ event,
+ event.getAction(),
+ /* metaState */ 0, /* isSynthesizedImeKey */
+ true);
+ }
+ }
+
+ public GeckoEditable(@NonNull final GeckoSession session) {
+ if (DEBUG) {
+ // Called by SessionTextInput.
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mSession = new WeakReference<>(session);
+ mText = new AsyncText();
+ mActions = new ConcurrentLinkedQueue<Action>();
+
+ final Class<?>[] PROXY_INTERFACES = {Editable.class};
+ mProxy =
+ (Editable) Proxy.newProxyInstance(Editable.class.getClassLoader(), PROXY_INTERFACES, this);
+
+ mIcRunHandler = mIcPostHandler = ThreadUtils.getUiHandler();
+ }
+
+ @Override // IGeckoEditableParent
+ public void setDefaultChild(final IGeckoEditableChild child) {
+ if (DEBUG) {
+ // On Gecko or binder thread.
+ Log.d(LOGTAG, "setDefaultEditableChild " + child);
+ }
+ mDefaultChild = child;
+ }
+
+ public void setListener(final SessionTextInput.EditableListener newListener) {
+ if (DEBUG) {
+ // Called by SessionTextInput.
+ ThreadUtils.assertOnUiThread();
+ Log.d(LOGTAG, "setListener " + newListener);
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "onViewChange (set listener)");
+ }
+
+ mListener = newListener;
+ }
+ });
+ }
+
+ private boolean onIcThread() {
+ return mIcRunHandler.getLooper() == Looper.myLooper();
+ }
+
+ private void assertOnIcThread() {
+ ThreadUtils.assertOnThread(mIcRunHandler.getLooper().getThread(), AssertBehavior.THROW);
+ }
+
+ private Object getField(final Object obj, final String field, final Object def) {
+ try {
+ return obj.getClass().getField(field).get(obj);
+ } catch (final Exception e) {
+ return def;
+ }
+ }
+
+ // Flags for icMaybeSendComposition
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SEND_COMPOSITION_USE_ENTIRE_TEXT,
+ SEND_COMPOSITION_NOTIFY_GECKO,
+ SEND_COMPOSITION_KEEP_CURRENT
+ })
+ public @interface CompositionFlags {}
+
+ // If text has composing spans, treat the entire text as a Gecko composition,
+ // instead of just the spanned part.
+ private static final int SEND_COMPOSITION_USE_ENTIRE_TEXT = 1 << 0;
+ // Notify Gecko of the new composition ranges;
+ // otherwise, the caller is responsible for notifying Gecko.
+ private static final int SEND_COMPOSITION_NOTIFY_GECKO = 1 << 1;
+ // Keep the current composition when updating;
+ // composition is not updated if there is no current composition.
+ private static final int SEND_COMPOSITION_KEEP_CURRENT = 1 << 2;
+
+ /**
+ * Send composition ranges to Gecko if the text has composing spans.
+ *
+ * @param sequence Text with possible composing spans
+ * @param flags Bitmask of SEND_COMPOSITION_* flags for updating composition.
+ * @return Whether there was a composition
+ */
+ private boolean icMaybeSendComposition(
+ final CharSequence sequence, @CompositionFlags final int flags) throws RemoteException {
+ final boolean useEntireText = (flags & SEND_COMPOSITION_USE_ENTIRE_TEXT) != 0;
+ final boolean notifyGecko = (flags & SEND_COMPOSITION_NOTIFY_GECKO) != 0;
+ final boolean keepCurrent = (flags & SEND_COMPOSITION_KEEP_CURRENT) != 0;
+ final int updateFlags = keepCurrent ? GeckoEditableChild.FLAG_KEEP_CURRENT_COMPOSITION : 0;
+
+ if (!keepCurrent) {
+ // If keepCurrent is true, the composition may not actually be updated;
+ // so we may still need to update the composition in the future.
+ mNeedUpdateComposition = false;
+ }
+
+ int selStart = Selection.getSelectionStart(sequence);
+ int selEnd = Selection.getSelectionEnd(sequence);
+
+ if (sequence instanceof Spanned) {
+ final Spanned text = (Spanned) sequence;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ boolean found = false;
+ int composingStart = useEntireText ? 0 : Integer.MAX_VALUE;
+ int composingEnd = useEntireText ? text.length() : 0;
+
+ // Find existence and range of any composing spans (spans with the
+ // SPAN_COMPOSING flag set).
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) == 0) {
+ continue;
+ }
+ found = true;
+ if (useEntireText) {
+ break;
+ }
+ composingStart = Math.min(composingStart, text.getSpanStart(span));
+ composingEnd = Math.max(composingEnd, text.getSpanEnd(span));
+ }
+
+ if (useEntireText && (selStart < 0 || selEnd < 0)) {
+ selStart = composingEnd;
+ selEnd = composingEnd;
+ }
+
+ if (found) {
+ if (selStart < composingStart || selEnd > composingEnd) {
+ // GBoard will set caret position that is out of composing
+ // range. Unfortunately, Gecko doesn't support this caret
+ // position. So we shouldn't set composing range data now.
+ // But this is temporary composing range, then GBoard will
+ // set valid range soon.
+ if (DEBUG) {
+ final StringBuilder sb =
+ new StringBuilder("icSendComposition(): invalid caret position. ");
+ sb.append("composing = ")
+ .append(composingStart)
+ .append("-")
+ .append(composingEnd)
+ .append(", selection = ")
+ .append(selStart)
+ .append("-")
+ .append(selEnd);
+ Log.d(LOGTAG, sb.toString());
+ }
+ } else {
+ icSendComposition(text, selStart, selEnd, composingStart, composingEnd);
+ if (notifyGecko) {
+ mFocusedChild.onImeUpdateComposition(composingStart, composingEnd, updateFlags);
+ }
+ return true;
+ }
+ }
+ }
+
+ if (notifyGecko) {
+ // Set the selection by using a composition without ranges.
+ final Spanned currentText = mText.getCurrentText();
+ if (Selection.getSelectionStart(currentText) != selStart
+ || Selection.getSelectionEnd(currentText) != selEnd) {
+ // Gecko's selection is different of requested selection, so
+ // we have to set selection of Gecko side.
+ // If selection is same, it is unnecessary to update it.
+ // This may be race with Gecko's updating selection via
+ // JavaScript or keyboard event. But we don't know whether
+ // Gecko is during updating selection.
+ mFocusedChild.onImeUpdateComposition(selStart, selEnd, updateFlags);
+ }
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "icSendComposition(): no composition");
+ }
+ return false;
+ }
+
+ private void icSendComposition(
+ final Spanned text,
+ final int selStart,
+ final int selEnd,
+ final int composingStart,
+ final int composingEnd)
+ throws RemoteException {
+ if (DEBUG) {
+ assertOnIcThread();
+ final StringBuilder sb = new StringBuilder("icSendComposition(");
+ sb.append("\"")
+ .append(text)
+ .append("\"")
+ .append(", range = ")
+ .append(composingStart)
+ .append("-")
+ .append(composingEnd)
+ .append(", selection = ")
+ .append(selStart)
+ .append("-")
+ .append(selEnd)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (selEnd >= composingStart && selEnd <= composingEnd) {
+ mFocusedChild.onImeAddCompositionRange(
+ selEnd - composingStart,
+ selEnd - composingStart,
+ IME_RANGE_CARETPOSITION,
+ 0,
+ 0,
+ false,
+ 0,
+ 0,
+ 0);
+ }
+
+ int rangeStart = composingStart;
+ final TextPaint tp = new TextPaint();
+ final TextPaint emptyTp = new TextPaint();
+ // set initial foreground color to 0, because we check for tp.getColor() == 0
+ // below to decide whether to pass a foreground color to Gecko
+ emptyTp.setColor(0);
+ do {
+ final int rangeType;
+ int rangeStyles = 0;
+ int rangeLineStyle = IME_RANGE_LINE_NONE;
+ boolean rangeBoldLine = false;
+ int rangeForeColor = 0, rangeBackColor = 0, rangeLineColor = 0;
+ int rangeEnd = text.nextSpanTransition(rangeStart, composingEnd, Object.class);
+
+ if (selStart > rangeStart && selStart < rangeEnd) {
+ rangeEnd = selStart;
+ } else if (selEnd > rangeStart && selEnd < rangeEnd) {
+ rangeEnd = selEnd;
+ }
+ final CharacterStyle[] styleSpans = text.getSpans(rangeStart, rangeEnd, CharacterStyle.class);
+
+ if (DEBUG) {
+ Log.d(LOGTAG, " found " + styleSpans.length + " spans @ " + rangeStart + "-" + rangeEnd);
+ }
+
+ if (styleSpans.length == 0) {
+ rangeType =
+ (selStart == rangeStart && selEnd == rangeEnd)
+ ? IME_RANGE_SELECTEDRAWTEXT
+ : IME_RANGE_RAWINPUT;
+ } else {
+ rangeType =
+ (selStart == rangeStart && selEnd == rangeEnd)
+ ? IME_RANGE_SELECTEDCONVERTEDTEXT
+ : IME_RANGE_CONVERTEDTEXT;
+ tp.set(emptyTp);
+ for (final CharacterStyle span : styleSpans) {
+ span.updateDrawState(tp);
+ }
+ int tpUnderlineColor = 0;
+ float tpUnderlineThickness = 0.0f;
+
+ // These TextPaint fields only exist on Android ICS+ and are not in the SDK.
+ tpUnderlineColor = (Integer) getField(tp, "underlineColor", 0);
+ tpUnderlineThickness = (Float) getField(tp, "underlineThickness", 0.0f);
+ if (tpUnderlineColor != 0) {
+ rangeStyles |= IME_RANGE_UNDERLINE | IME_RANGE_LINECOLOR;
+ rangeLineColor = tpUnderlineColor;
+ // Approximately translate underline thickness to what Gecko understands
+ if (tpUnderlineThickness <= 0.5f) {
+ rangeLineStyle = IME_RANGE_LINE_DOTTED;
+ } else {
+ rangeLineStyle = IME_RANGE_LINE_SOLID;
+ if (tpUnderlineThickness >= 2.0f) {
+ rangeBoldLine = true;
+ }
+ }
+ } else if (tp.isUnderlineText()) {
+ rangeStyles |= IME_RANGE_UNDERLINE;
+ rangeLineStyle = IME_RANGE_LINE_SOLID;
+ }
+ if (tp.getColor() != 0) {
+ rangeStyles |= IME_RANGE_FORECOLOR;
+ rangeForeColor = tp.getColor();
+ }
+ if (tp.bgColor != 0) {
+ rangeStyles |= IME_RANGE_BACKCOLOR;
+ rangeBackColor = tp.bgColor;
+ }
+ }
+ mFocusedChild.onImeAddCompositionRange(
+ rangeStart - composingStart,
+ rangeEnd - composingStart,
+ rangeType,
+ rangeStyles,
+ rangeLineStyle,
+ rangeBoldLine,
+ rangeForeColor,
+ rangeBackColor,
+ rangeLineColor);
+ rangeStart = rangeEnd;
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ " added "
+ + rangeType
+ + " : "
+ + Integer.toHexString(rangeStyles)
+ + " : "
+ + Integer.toHexString(rangeForeColor)
+ + " : "
+ + Integer.toHexString(rangeBackColor));
+ }
+ } while (rangeStart < composingEnd);
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void sendKeyEvent(
+ final @Nullable View view, final int action, final @NonNull KeyEvent event) {
+ final Editable editable = mProxy;
+ final KeyListener keyListener = TextKeyListener.getInstance();
+ final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event);
+
+ // We only let TextKeyListener do UI things on the UI thread.
+ final View v = ThreadUtils.isOnUiThread() ? view : null;
+ final int keyCode = translatedEvent.getKeyCode();
+ final boolean handled;
+
+ if (shouldSkipKeyListener(keyCode, translatedEvent)) {
+ handled = false;
+ } else if (action == KeyEvent.ACTION_DOWN) {
+ setSuppressKeyUp(true);
+ handled = keyListener.onKeyDown(v, editable, keyCode, translatedEvent);
+ } else if (action == KeyEvent.ACTION_UP) {
+ handled = keyListener.onKeyUp(v, editable, keyCode, translatedEvent);
+ } else {
+ handled = keyListener.onKeyOther(v, editable, translatedEvent);
+ }
+
+ if (!handled) {
+ sendKeyEvent(translatedEvent, action, TextKeyListener.getMetaState(editable));
+ }
+
+ if (action == KeyEvent.ACTION_DOWN) {
+ if (!handled) {
+ // Usually, the down key listener call above adjusts meta states for us.
+ // However, if the call didn't handle the event, we have to manually
+ // adjust meta states so the meta states remain consistent.
+ TextKeyListener.adjustMetaAfterKeypress(editable);
+ }
+ setSuppressKeyUp(false);
+ }
+ }
+
+ private void sendKeyEvent(final @NonNull KeyEvent event, final int action, final int metaState) {
+ if (DEBUG) {
+ assertOnIcThread();
+ Log.d(LOGTAG, "sendKeyEvent(" + event + ", " + action + ", " + metaState + ")");
+ }
+ /*
+ We are actually sending two events to Gecko here,
+ 1. Event from the event parameter (key event)
+ 2. Sync event from the icOfferAction call
+ The first event is a normal event that does not reply back to us,
+ the second sync event will have a reply, during which we see that there is a pending
+ event-type action, and update the shadow text accordingly.
+ */
+ try {
+ if (mFocusedChild == null) {
+ if (mDefaultChild == null) {
+ Log.w(LOGTAG, "Discarding key event");
+ return;
+ }
+ // Not focused; send simple key event to chrome window.
+ onKeyEvent(mDefaultChild, event, action, metaState, /* isSynthesizedImeKey */ false);
+ return;
+ }
+
+ // Most IMEs handle arrow key, then set caret position. But GBoard
+ // doesn't handle it. GBoard will dispatch KeyEvent for arrow left/right
+ // even if having IME composition.
+ // Since Gecko doesn't dispatch keypress during IME composition due to
+ // DOM UI events spec, we have to emulate arrow key's behaviour.
+ boolean commitCompositionBeforeKeyEvent = action == KeyEvent.ACTION_DOWN;
+ if (isComposing(mText.getShadowText())
+ && action == KeyEvent.ACTION_DOWN
+ && event.hasNoModifiers()) {
+ final int selStart = Selection.getSelectionStart(mText.getShadowText());
+ final int selEnd = Selection.getSelectionEnd(mText.getShadowText());
+ if (selStart == selEnd) {
+ // If dispatching arrow left/right key into composition,
+ // we update IME caret.
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ if (getComposingStart(mText.getShadowText()) < selStart) {
+ Selection.setSelection(getEditable(), selStart - 1, selStart - 1);
+ mNeedUpdateComposition = true;
+ commitCompositionBeforeKeyEvent = false;
+ } else if (selStart == 0) {
+ // Keep current composition
+ commitCompositionBeforeKeyEvent = false;
+ }
+ break;
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ if (getComposingEnd(mText.getShadowText()) > selEnd) {
+ Selection.setSelection(getEditable(), selStart + 1, selStart + 1);
+ mNeedUpdateComposition = true;
+ commitCompositionBeforeKeyEvent = false;
+ } else if (selEnd == mText.getShadowText().length()) {
+ // Keep current composition
+ commitCompositionBeforeKeyEvent = false;
+ }
+ break;
+ }
+ }
+ }
+
+ // Focused; key event may go to chrome window or to content window.
+ if (mNeedUpdateComposition) {
+ icMaybeSendComposition(mText.getShadowText(), SEND_COMPOSITION_NOTIFY_GECKO);
+ }
+
+ if (commitCompositionBeforeKeyEvent) {
+ mFocusedChild.onImeRequestCommit();
+ }
+ onKeyEvent(mFocusedChild, event, action, metaState, /* isSynthesizedImeKey */ false);
+ icOfferAction(new Action(Action.TYPE_EVENT));
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ private boolean shouldSkipKeyListener(final int keyCode, final @NonNull KeyEvent event) {
+ if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ return true;
+ }
+
+ // Preserve enter and tab keys for the browser
+ if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_TAB) {
+ return true;
+ }
+ // BaseKeyListener returns false even if it handled these keys for us,
+ // so we skip the key listener entirely and handle these ourselves
+ if (keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
+ return true;
+ }
+ return false;
+ }
+
+ private static KeyEvent translateSonyXperiaGamepadKeys(final int keyCode, final KeyEvent event) {
+ // The cross and circle button mappings may be swapped in the different regions so
+ // determine if they are swapped so the proper key codes can be mapped to the keys
+ final boolean areKeysSwapped = areSonyXperiaGamepadKeysSwapped();
+
+ int translatedKeyCode = keyCode;
+ // If a Sony Xperia, remap the cross and circle buttons to buttons
+ // A and B for the gamepad API
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BACK:
+ translatedKeyCode =
+ (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_A : KeyEvent.KEYCODE_BUTTON_B);
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ translatedKeyCode =
+ (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_B : KeyEvent.KEYCODE_BUTTON_A);
+ break;
+
+ default:
+ return event;
+ }
+
+ return new KeyEvent(event.getAction(), translatedKeyCode);
+ }
+
+ private static final int SONY_XPERIA_GAMEPAD_DEVICE_ID = 196611;
+
+ private static boolean isSonyXperiaGamepadKeyEvent(final KeyEvent event) {
+ return (event.getDeviceId() == SONY_XPERIA_GAMEPAD_DEVICE_ID
+ && "Sony Ericsson".equals(Build.MANUFACTURER)
+ && ("R800".equals(Build.MODEL) || "R800i".equals(Build.MODEL)));
+ }
+
+ private static boolean areSonyXperiaGamepadKeysSwapped() {
+ // The cross and circle buttons on Sony Xperia phones are swapped
+ // in different regions
+ // http://developer.sonymobile.com/2011/02/13/xperia-play-game-keys/
+ final char DEFAULT_O_BUTTON_LABEL = 0x25CB;
+
+ boolean swapped = false;
+ final int[] deviceIds = InputDevice.getDeviceIds();
+
+ for (int i = 0; deviceIds != null && i < deviceIds.length; i++) {
+ final KeyCharacterMap keyCharacterMap = KeyCharacterMap.load(deviceIds[i]);
+ if (keyCharacterMap != null
+ && DEFAULT_O_BUTTON_LABEL
+ == keyCharacterMap.getDisplayLabel(KeyEvent.KEYCODE_DPAD_CENTER)) {
+ swapped = true;
+ break;
+ }
+ }
+ return swapped;
+ }
+
+ private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) {
+ if (isSonyXperiaGamepadKeyEvent(event)) {
+ return translateSonyXperiaGamepadKeys(keyCode, event);
+ }
+ return event;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public Editable getEditable() {
+ if (!onIcThread()) {
+ // Android may be holding an old InputConnection; ignore
+ if (DEBUG) {
+ Log.i(LOGTAG, "getEditable() called on non-IC thread");
+ }
+ return null;
+ }
+ if (mListener == null) {
+ // We haven't initialized or we've been destroyed.
+ return null;
+ }
+ return mProxy;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void setBatchMode(final boolean inBatchMode) {
+ if (!onIcThread()) {
+ // Android may be holding an old InputConnection; ignore
+ if (DEBUG) {
+ Log.i(LOGTAG, "setBatchMode() called on non-IC thread");
+ }
+ return;
+ }
+
+ mInBatchMode = inBatchMode;
+
+ if (!inBatchMode && mFocusedChild != null) {
+ // We may not commit composition on Gecko even if Java side has
+ // no composition. So we have to sync composition state with Gecko
+ // when batch edit is done.
+ //
+ // i.e. Although finishComposingText removes composing span, we
+ // don't commit current composition yet.
+ final Editable editable = getEditable();
+ if (editable != null && !isComposing(editable)) {
+ try {
+ mFocusedChild.onImeRequestCommit();
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+ // Committing composition doesn't change text, so we can sync shadow text.
+ }
+
+ if (!inBatchMode && mNeedSync) {
+ icSyncShadowText();
+ }
+ }
+
+ /* package */ void icSyncShadowText() {
+ if (mListener == null) {
+ // Not yet attached or already destroyed.
+ return;
+ }
+
+ if (mInBatchMode || !mActions.isEmpty()) {
+ mNeedSync = true;
+ return;
+ }
+
+ mNeedSync = false;
+ mText.syncShadowText(mListener);
+ }
+
+ private void setSuppressKeyUp(final boolean suppress) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ // Suppress key up event generated as a result of
+ // translating characters to key events
+ mSuppressKeyUp = suppress;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public Handler setInputConnectionHandler(final Handler handler) {
+ if (handler == mIcRunHandler) {
+ return mIcRunHandler;
+ }
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ // There are three threads at this point: Gecko thread, old IC thread, and new IC
+ // thread, and we want to safely switch from old IC thread to new IC thread.
+ // We first send a TYPE_SET_HANDLER action to the Gecko thread; this ensures that
+ // the Gecko thread is stopped at a known point. At the same time, the old IC
+ // thread blocks on the action; this ensures that the old IC thread is stopped at
+ // a known point. Finally, inside the Gecko thread, we post a Runnable to the old
+ // IC thread; this Runnable switches from old IC thread to new IC thread. We
+ // switch IC thread on the old IC thread to ensure any pending Runnables on the
+ // old IC thread are processed before we switch over. Inside the Gecko thread, we
+ // also post a Runnable to the new IC thread; this Runnable blocks until the
+ // switch is complete; this ensures that the new IC thread won't accept
+ // InputConnection calls until after the switch.
+
+ handler.post(
+ new Runnable() { // Make the new IC thread wait.
+ @Override
+ public void run() {
+ synchronized (handler) {
+ while (mIcRunHandler != handler) {
+ try {
+ handler.wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ }
+ }
+ });
+
+ icOfferAction(Action.newSetHandler(handler));
+ return handler;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void postToInputConnection(final Runnable runnable) {
+ mIcPostHandler.post(runnable);
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void requestCursorUpdates(@CursorMonitorMode final int requestMode) {
+ try {
+ if (mFocusedChild != null) {
+ mFocusedChild.onImeRequestCursorUpdates(requestMode);
+ }
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void insertImage(final @NonNull byte[] data, final @NonNull String mimeType) {
+ if (mFocusedChild == null) {
+ return;
+ }
+
+ try {
+ mFocusedChild.onImeInsertImage(data, mimeType);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call to insert image failed", e);
+ }
+ }
+
+ private void geckoSetIcHandler(final Handler newHandler) {
+ // On Gecko or binder thread.
+ mIcPostHandler.post(
+ new Runnable() { // posting to old IC thread
+ @Override
+ public void run() {
+ synchronized (newHandler) {
+ mIcRunHandler = newHandler;
+ newHandler.notify();
+ }
+ }
+ });
+
+ // At this point, all future Runnables should be posted to the new IC thread, but
+ // we don't switch mIcRunHandler yet because there may be pending Runnables on the
+ // old IC thread still waiting to run.
+ mIcPostHandler = newHandler;
+ }
+
+ private void geckoActionReply(final Action action) {
+ // On Gecko or binder thread.
+ if (action == null) {
+ Log.w(LOGTAG, "Mismatched reply");
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "reply: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")");
+ }
+ switch (action.mType) {
+ case Action.TYPE_REPLACE_TEXT:
+ {
+ final Spanned currentText = mText.getCurrentText();
+ final int actionNewEnd = action.mStart + action.mSequence.length();
+ if (mLastTextChangeStart > mLastTextChangeNewEnd
+ || mLastTextChangeNewEnd > currentText.length()
+ || action.mStart < mLastTextChangeStart
+ || actionNewEnd > mLastTextChangeNewEnd) {
+ // Replace-text action doesn't match our text change.
+ break;
+ }
+
+ int indexInText =
+ TextUtils.indexOf(
+ currentText, action.mSequence, action.mStart, mLastTextChangeNewEnd);
+ if (indexInText < 0 && action.mStart != mLastTextChangeStart) {
+ final String changedText =
+ TextUtils.substring(currentText, mLastTextChangeStart, actionNewEnd);
+ indexInText = changedText.lastIndexOf(action.mSequence.toString());
+ if (indexInText >= 0) {
+ indexInText += mLastTextChangeStart;
+ }
+ }
+ if (indexInText < 0) {
+ // Replace-text action doesn't match our current text.
+ break;
+ }
+
+ final int selStart = Selection.getSelectionStart(currentText);
+ final int selEnd = Selection.getSelectionEnd(currentText);
+
+ // Replace-text action matches our current text; copy the new spans to the
+ // current text.
+ mText.currentReplace(
+ indexInText, indexInText + action.mSequence.length(), action.mSequence);
+ // Make sure selection is preserved.
+ mText.currentSetSelection(selStart, selEnd);
+
+ // The text change is caused by the replace-text event. If the text change
+ // replaced the previous selection, we need to rely on Gecko for an updated
+ // selection, so don't ignore selection change. However, if the text change
+ // did not replace the previous selection, we can ignore the Gecko selection
+ // in favor of the Java selection.
+ mIgnoreSelectionChange = !mLastTextChangeReplacedSelection;
+ break;
+ }
+
+ case Action.TYPE_SET_SPAN:
+ final int len = mText.getCurrentText().length();
+ if (action.mStart > len
+ || action.mEnd > len
+ || !TextUtils.substring(mText.getCurrentText(), action.mStart, action.mEnd)
+ .equals(action.mSequence)) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "discarding stale set span call");
+ }
+ break;
+ }
+ if ((action.mSpanObject == Selection.SELECTION_START
+ || action.mSpanObject == Selection.SELECTION_END)
+ && (action.mStart < mLastTextChangeStart && action.mEnd < mLastTextChangeStart
+ || action.mStart > mLastTextChangeOldEnd && action.mEnd > mLastTextChangeOldEnd)) {
+ // Use the Java selection if, between text-change notification and replace-text
+ // processing, we specifically set the selection to outside the replaced range.
+ mLastTextChangeReplacedSelection = false;
+ }
+ mText.currentSetSpan(action.mSpanObject, action.mStart, action.mEnd, action.mSpanFlags);
+ break;
+
+ case Action.TYPE_REMOVE_SPAN:
+ mText.currentRemoveSpan(action.mSpanObject);
+ break;
+
+ case Action.TYPE_SET_HANDLER:
+ geckoSetIcHandler(action.mHandler);
+ break;
+ }
+ }
+
+ private synchronized boolean binderCheckToken(final IBinder token, final boolean allowNull) {
+ // Verify that we're getting an IME notification from the currently focused child.
+ if (mFocusedToken == token || (mFocusedToken == null && allowNull)) {
+ return true;
+ }
+ Log.w(LOGTAG, "Invalid token");
+ return false;
+ }
+
+ @Override // IGeckoEditableParent
+ public void notifyIME(final IGeckoEditableChild child, @IMENotificationType final int type) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ // NOTIFY_IME_REPLY_EVENT is logged separately, inside geckoActionReply()
+ if (type != SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ Log.d(
+ LOGTAG,
+ "notifyIME("
+ + getConstantName(SessionTextInput.EditableListener.class, "NOTIFY_IME_", type)
+ + ")");
+ }
+ }
+
+ final IBinder token = child.asBinder();
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN) {
+ synchronized (this) {
+ if (mFocusedToken != null && mFocusedToken != token && mFocusedToken.pingBinder()) {
+ // Focused child already exists and is alive.
+ Log.w(LOGTAG, "Already focused");
+ return;
+ }
+ mFocusedToken = token;
+ return;
+ }
+ } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB) {
+ // Always from parent process.
+ ThreadUtils.assertOnGeckoThread();
+ } else if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR) {
+ synchronized (this) {
+ onTextChange(token, "", 0, Integer.MAX_VALUE, false);
+ mActions.clear();
+ mFocusedToken = null;
+ }
+ } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ geckoActionReply(mActions.poll());
+ if (!mActions.isEmpty()) {
+ // Only post to IC thread below when the queue is empty.
+ return;
+ }
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icNotifyIME(child, type);
+ }
+ });
+ }
+
+ /* package */ void icNotifyIME(
+ final IGeckoEditableChild child, @IMENotificationType final int type) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ if (mNeedSync) {
+ icSyncShadowText();
+ }
+ return;
+ }
+
+ switch (type) {
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_FOCUS:
+ if (mFocusedChild != null) {
+ // Already focused, so blur first.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ false);
+ }
+
+ mFocusedChild = child;
+ mNeedSync = false;
+ mText.syncShadowText(/* listener */ null);
+
+ // Most of the time notifyIMEContext comes _before_ notifyIME, but sometimes it
+ // comes _after_ notifyIME. In that case, the state is disabled here, and
+ // notifyIMEContext is responsible for calling restartInput.
+ if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ mIMEState = SessionTextInput.EditableListener.IME_STATE_UNKNOWN;
+ } else {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true);
+ }
+ break;
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR:
+ if (mFocusedChild != null) {
+ mFocusedChild = null;
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ true);
+ }
+ break;
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB:
+ toggleSoftInput(/* force */ true, mIMEState);
+ return; // Don't notify listener.
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_TO_COMMIT_COMPOSITION:
+ {
+ // Gecko already committed its composition. However, Android keyboards
+ // have trouble dealing with us removing the composition manually on the
+ // Java side. Therefore, we keep the composition intact on the Java side.
+ // The text content should still be in-sync on both sides.
+ //
+ // Nevertheless, if we somehow lost the composition, we must force the
+ // keyboard to reset.
+ if (isComposing(mText.getShadowText())) {
+ // Still have composition; no need to reset.
+ return; // Don't notify listener.
+ }
+ // No longer have composition; perform reset.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ false);
+ return; // Don't notify listener.
+ }
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN:
+ case SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT:
+ case SessionTextInput.EditableListener.NOTIFY_IME_TO_CANCEL_COMPOSITION:
+ default:
+ throw new IllegalArgumentException("Invalid notifyIME type: " + type);
+ }
+
+ if (mListener != null) {
+ mListener.notifyIME(type);
+ }
+ }
+
+ @Override // IGeckoEditableParent
+ public void notifyIMEContext(
+ final IBinder token,
+ @IMEState final int state,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ final String autocapitalize,
+ @IMEContextFlags final int flags) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("notifyIMEContext(");
+ sb.append(getConstantName(SessionTextInput.EditableListener.class, "IME_STATE_", state))
+ .append(", type=\"")
+ .append(typeHint)
+ .append("\", inputmode=\"")
+ .append(modeHint)
+ .append("\", autocapitalize=\"")
+ .append(autocapitalize)
+ .append("\", flags=0x")
+ .append(Integer.toHexString(flags))
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ // Regular notifyIMEContext calls all come from the parent process (with the default child),
+ // so always allow calls from there. We can get additional notifyIMEContext calls during
+ // a session transfer; calls in those cases can come from child processes, and we must
+ // perform a token check in that situation.
+ if (token != mDefaultChild.asBinder() && !binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icNotifyIMEContext(state, typeHint, modeHint, actionHint, autocapitalize, flags);
+ }
+ });
+ }
+
+ /* package */ void icNotifyIMEContext(
+ @IMEState final int originalState,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ final String autocapitalize,
+ @IMEContextFlags final int flags) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ // For some input type we will use a widget to display the ui, for those we must not
+ // display the ime. We can display a widget for date and time types and, if the sdk version
+ // is 11 or greater, for datetime/month/week as well.
+ final int state;
+ if ((typeHint != null
+ && (typeHint.equalsIgnoreCase("date")
+ || typeHint.equalsIgnoreCase("time")
+ || typeHint.equalsIgnoreCase("month")
+ || typeHint.equalsIgnoreCase("week")
+ || typeHint.equalsIgnoreCase("datetime-local")))
+ || (modeHint != null && modeHint.equals("none"))) {
+ state = SessionTextInput.EditableListener.IME_STATE_DISABLED;
+ } else {
+ state = originalState;
+ }
+
+ final int oldState = mIMEState;
+ mIMEState = state;
+ mIMETypeHint = (typeHint == null) ? "" : typeHint;
+ mIMEModeHint = (modeHint == null) ? "" : modeHint;
+ mIMEActionHint = (actionHint == null) ? "" : actionHint;
+ mIMEAutocapitalize = (autocapitalize == null) ? "" : autocapitalize;
+ mIMEFlags = flags;
+
+ if (mListener != null) {
+ mListener.notifyIMEContext(state, typeHint, modeHint, actionHint, flags);
+ }
+
+ if (mFocusedChild == null) {
+ // We have no focus.
+ return;
+ }
+
+ if ((flags & SessionTextInput.EditableListener.IME_FOCUS_NOT_CHANGED) != 0) {
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("icNotifyIMEContext: ");
+ sb.append("focus isn't changed. oldState=")
+ .append(oldState)
+ .append(", newState=")
+ .append(state);
+ Log.d(LOGTAG, sb.toString());
+ }
+ if (((oldState == SessionTextInput.EditableListener.IME_STATE_ENABLED
+ || oldState == SessionTextInput.EditableListener.IME_STATE_PASSWORD)
+ && state == SessionTextInput.EditableListener.IME_STATE_DISABLED)
+ || (oldState == SessionTextInput.EditableListener.IME_STATE_DISABLED
+ && (state == SessionTextInput.EditableListener.IME_STATE_ENABLED
+ || state == SessionTextInput.EditableListener.IME_STATE_PASSWORD))) {
+ // Even if focus isn't changed, software keyboard state is changed.
+ // We have to show or dismiss it.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ true);
+ return;
+ }
+ }
+
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ // When focus is being lost, icNotifyIME with NOTIFY_IME_OF_BLUR
+ // will dismiss it.
+ // So ignore to control software keyboard at this time.
+ return;
+ }
+
+ // We changed state while focused. If the old state is unknown, it means this
+ // notifyIMEContext call came _after_ the notifyIME call, so we need to call
+ // restartInput(FOCUS) here (see comment in icNotifyIME). Otherwise, this change
+ // counts as a content change.
+ if (oldState == SessionTextInput.EditableListener.IME_STATE_UNKNOWN) {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true);
+ } else if (oldState != SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ false);
+ }
+ }
+
+ private void icRestartInput(
+ @GeckoSession.RestartReason final int reason, final boolean toggleSoftInput) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "restartInput(" + reason + ", " + toggleSoftInput + ')');
+ }
+
+ final GeckoSession session = mSession.get();
+ if (session != null) {
+ session.getTextInput().getDelegate().restartInput(session, reason);
+ }
+
+ if (!toggleSoftInput) {
+ return;
+ }
+ postToInputConnection(
+ new Runnable() {
+ @Override
+ public void run() {
+ int state = mIMEState;
+ if (reason == GeckoSession.TextInputDelegate.RESTART_REASON_BLUR
+ && mFocusedChild == null) {
+ // On blur, notifyIMEContext() is called after notifyIME(). Therefore,
+ // mIMEState is not up-to-date here and we need to override it.
+ state = SessionTextInput.EditableListener.IME_STATE_DISABLED;
+ }
+ toggleSoftInput(/* force */ false, state);
+ }
+ });
+ }
+ });
+ }
+
+ public void onCreateInputConnection(final EditorInfo outAttrs) {
+ final int state = mIMEState;
+ final String typeHint = mIMETypeHint;
+ final String modeHint = mIMEModeHint;
+ final String actionHint = mIMEActionHint;
+ final String autocapitalize = mIMEAutocapitalize;
+ final int flags = mIMEFlags;
+
+ // Some keyboards require us to fill out outAttrs even if we return null.
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE;
+ outAttrs.actionLabel = null;
+
+ if (modeHint.equals("none")) {
+ // inputmode=none hides VKB at force.
+ outAttrs.inputType = InputType.TYPE_NULL;
+ toggleSoftInput(/* force */ true, SessionTextInput.EditableListener.IME_STATE_DISABLED);
+ return;
+ }
+
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ outAttrs.inputType = InputType.TYPE_NULL;
+ toggleSoftInput(/* force */ false, state);
+ return;
+ }
+
+ // We give priority to typeHint so that content authors can't annoy
+ // users by doing dumb things like opening the numeric keyboard for
+ // an email form field.
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
+ if (state == SessionTextInput.EditableListener.IME_STATE_PASSWORD
+ || "password".equalsIgnoreCase(typeHint)) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_PASSWORD;
+ } else if (typeHint.equalsIgnoreCase("url") || modeHint.equals("mozAwesomebar")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_URI;
+ } else if (typeHint.equalsIgnoreCase("email")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ } else if (typeHint.equalsIgnoreCase("tel")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_PHONE;
+ } else if (typeHint.equalsIgnoreCase("number") || typeHint.equalsIgnoreCase("range")) {
+ outAttrs.inputType =
+ InputType.TYPE_CLASS_NUMBER
+ | InputType.TYPE_NUMBER_VARIATION_NORMAL
+ | InputType.TYPE_NUMBER_FLAG_DECIMAL;
+ } else {
+ // We look at modeHint
+ if (modeHint.equals("tel")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_PHONE;
+ } else if (modeHint.equals("url")) {
+ outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_URI;
+ } else if (modeHint.equals("email")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ } else if (modeHint.equals("numeric")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_NORMAL;
+ } else if (modeHint.equals("decimal")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL;
+ } else {
+ // TYPE_TEXT_FLAG_IME_MULTI_LINE flag makes the fullscreen IME line wrap
+ outAttrs.inputType |=
+ InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE;
+ }
+ }
+
+ if (autocapitalize.equals("characters")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS;
+ } else if (autocapitalize.equals("none")) {
+ // not set anymore.
+ } else if (autocapitalize.equals("sentences")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
+ } else if (autocapitalize.equals("words")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_WORDS;
+ } else if (modeHint.length() == 0
+ && (outAttrs.inputType & InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE) != 0
+ && !typeHint.equalsIgnoreCase("text")) {
+ // auto-capitalized mode is the default for types other than text (bug 871884)
+ // except to password, url and email.
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
+ }
+
+ if (actionHint.equals("enter")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE;
+ } else if (actionHint.equals("go")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_GO;
+ } else if (actionHint.equals("done")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE;
+ } else if (actionHint.equals("next") || actionHint.equals("maybenext")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NEXT;
+ } else if (actionHint.equals("previous")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_PREVIOUS;
+ } else if (actionHint.equals("search") || typeHint.equals("search")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_SEARCH;
+ } else if (actionHint.equals("send")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_SEND;
+ } else if (actionHint.length() > 0) {
+ if (DEBUG) Log.w(LOGTAG, "Unexpected actionHint=\"" + actionHint + "\"");
+ outAttrs.actionLabel = actionHint;
+ }
+
+ if ((flags & SessionTextInput.EditableListener.IME_FLAG_PRIVATE_BROWSING) != 0) {
+ outAttrs.imeOptions |= InputMethods.IME_FLAG_NO_PERSONALIZED_LEARNING;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && typeHint.length() == 0) {
+ // contenteditable allows image insertion.
+ outAttrs.contentMimeTypes = new String[] {"image/gif", "image/jpeg", "image/png"};
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ final Spanned currentText = mText.getCurrentText();
+ outAttrs.initialSelStart = Selection.getSelectionStart(currentText);
+ outAttrs.initialSelEnd = Selection.getSelectionEnd(currentText);
+ outAttrs.setInitialSurroundingText(currentText);
+ }
+
+ toggleSoftInput(/* force */ false, state);
+ }
+
+ /* package */ void toggleSoftInput(final boolean force, final int state) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "toggleSoftInput");
+ }
+ // Can be called from UI or IC thread.
+ final int flags = mIMEFlags;
+
+ // There are three paths that toggleSoftInput() can be called:
+ // 1) through calling restartInput(), which then indirectly calls
+ // onCreateInputConnection() and then toggleSoftInput().
+ // 2) through calling toggleSoftInput() directly from restartInput().
+ // This path is the fallback in case 1) does not happen.
+ // 3) through a system-generated onCreateInputConnection() call when the activity
+ // is restored from background, which then calls toggleSoftInput().
+ // mSoftInputReentrancyGuard is needed to ensure that between the different paths,
+ // the soft input is only toggled exactly once.
+
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ try {
+ final int reentrancyGuard = mSoftInputReentrancyGuard.incrementAndGet();
+ final boolean isReentrant = reentrancyGuard > 1;
+
+ // When using Find In Page, we can still receive notifyIMEContext calls due to the
+ // selection changing when highlighting. However in this case we don't want to
+ // show/hide the keyboard because the find box has the focus and is taking input from
+ // the keyboard.
+ final GeckoSession session = mSession.get();
+
+ if (session == null) {
+ return;
+ }
+
+ final View view = session.getTextInput().getView();
+ final boolean isFocused = (view == null) || view.hasFocus();
+
+ final boolean isUserAction =
+ ((flags & SessionTextInput.EditableListener.IME_FLAG_USER_ACTION) != 0);
+
+ if (!force && (isReentrant || !isFocused || !isUserAction)) {
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "toggleSoftInput: no-op, reentrant="
+ + isReentrant
+ + ", focused="
+ + isFocused
+ + ", user="
+ + isUserAction);
+ }
+ return;
+ }
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ session.getTextInput().getDelegate().hideSoftInput(session);
+ return;
+ }
+ {
+ final GeckoBundle bundle = new GeckoBundle();
+ // This bit is subtle. We want to force-zoom to the input
+ // if we're _not_ force-showing the virtual keyboard.
+ //
+ // We only force-show the virtual keyboard as a result of
+ // something that _doesn't_ switch the focus, and we don't
+ // want to move the view out of the focused editor unless
+ // we _actually_ show toggle the keyboard.
+ bundle.putBoolean("force", !force);
+ session.getEventDispatcher().dispatch("GeckoView:ZoomToInput", bundle);
+ }
+ session.getTextInput().getDelegate().showSoftInput(session);
+ } finally {
+ mSoftInputReentrancyGuard.decrementAndGet();
+ }
+ }
+ });
+ }
+
+ @Override // IGeckoEditableParent
+ public void onSelectionChange(
+ final IBinder token, final int start, final int end, final boolean causedOnlyByComposition) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onSelectionChange(");
+ sb.append(start)
+ .append(", ")
+ .append(end)
+ .append(", ")
+ .append(causedOnlyByComposition)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (mIgnoreSelectionChange) {
+ mIgnoreSelectionChange = false;
+ } else {
+ mText.currentSetSelection(start, end);
+ }
+
+ // We receive selection change notification after receiving replies for pending
+ // events, so we can reset text change bounds at this point.
+ mLastTextChangeStart = Integer.MAX_VALUE;
+ mLastTextChangeOldEnd = -1;
+ mLastTextChangeNewEnd = -1;
+ mLastTextChangeReplacedSelection = false;
+
+ if (causedOnlyByComposition) {
+ // It is unnecessary to sync shadow text since this change is by composition from Java
+ // side.
+ return;
+ }
+
+ // It is ready to synchronize Java text with Gecko text when no more input events is
+ // dispatched.
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icSyncShadowText();
+ }
+ });
+ }
+
+ private boolean geckoIsSameText(final int start, final int oldEnd, final CharSequence newText) {
+ return oldEnd - start == newText.length()
+ && TextUtils.regionMatches(mText.getCurrentText(), start, newText, 0, oldEnd - start);
+ }
+
+ @Override // IGeckoEditableParent
+ public void onTextChange(
+ final IBinder token,
+ final CharSequence text,
+ final int start,
+ final int unboundedOldEnd,
+ final boolean causedOnlyByComposition) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onTextChange(");
+ debugAppend(sb, text)
+ .append(", ")
+ .append(start)
+ .append(", ")
+ .append(unboundedOldEnd)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (unboundedOldEnd >= Integer.MAX_VALUE / 2) {
+ // Integer.MAX_VALUE / 2 is a magic number to synchronize all.
+ // (See GeckoEditableSupport::FlushIMEText.)
+ // Previous text transactions are unnecessary now, so we have to ignore it.
+ mActions.clear();
+ }
+
+ final int currentLength = mText.getCurrentText().length();
+ final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd;
+ final int newEnd = start + text.length();
+
+ if (start == 0 && unboundedOldEnd > currentLength && !causedOnlyByComposition) {
+ // | oldEnd > currentLength | signals entire text is cleared (e.g. for
+ // newly-focused editors). Simply replace the text in that case; replace in
+ // two steps to properly clear composing spans that span the whole range.
+ mText.currentReplace(0, currentLength, "");
+ mText.currentReplace(0, 0, text);
+
+ // Don't ignore the next selection change because we are re-syncing with Gecko
+ mIgnoreSelectionChange = false;
+
+ mLastTextChangeStart = Integer.MAX_VALUE;
+ mLastTextChangeOldEnd = -1;
+ mLastTextChangeNewEnd = -1;
+ mLastTextChangeReplacedSelection = false;
+
+ } else if (!geckoIsSameText(start, oldEnd, text)) {
+ final Spanned currentText = mText.getCurrentText();
+ final int selStart = Selection.getSelectionStart(currentText);
+ final int selEnd = Selection.getSelectionEnd(currentText);
+
+ // True if the selection was in the middle of the replaced text; in that case
+ // we don't know where to place the selection after replacement, and must rely
+ // on the Gecko selection.
+ mLastTextChangeReplacedSelection |=
+ (selStart >= start && selStart <= oldEnd) || (selEnd >= start && selEnd <= oldEnd);
+
+ // Gecko side initiated the text change. Replace in two steps to properly
+ // clear composing spans that span the whole range.
+ mText.currentReplace(start, oldEnd, "");
+ mText.currentReplace(start, start, text);
+
+ mLastTextChangeStart = Math.min(start, mLastTextChangeStart);
+ mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd);
+ mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd);
+
+ } else {
+ // Nothing to do because the text is the same. This could happen when
+ // the composition is updated for example, in which case we want to keep the
+ // Java selection.
+ final Action action = mActions.peek();
+ mIgnoreSelectionChange =
+ mIgnoreSelectionChange
+ || (action != null
+ && (action.mType == Action.TYPE_REPLACE_TEXT
+ || action.mType == Action.TYPE_SET_SPAN
+ || action.mType == Action.TYPE_REMOVE_SPAN));
+
+ mLastTextChangeStart = Math.min(start, mLastTextChangeStart);
+ mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd);
+ mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd);
+ }
+
+ // onTextChange is always followed by onSelectionChange, so we let
+ // onSelectionChange schedule a shadow text sync.
+ }
+
+ @Override // IGeckoEditableParent
+ public void onDefaultKeyEvent(final IBinder token, final KeyEvent event) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onDefaultKeyEvent(");
+ sb.append("action=")
+ .append(event.getAction())
+ .append(", ")
+ .append("keyCode=")
+ .append(event.getKeyCode())
+ .append(", ")
+ .append("metaState=")
+ .append(event.getMetaState())
+ .append(", ")
+ .append("time=")
+ .append(event.getEventTime())
+ .append(", ")
+ .append("repeatCount=")
+ .append(event.getRepeatCount())
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ // Allow default key processing even if we're not focused.
+ if (!binderCheckToken(token, /* allowNull */ true)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mListener == null) {
+ return;
+ }
+ mListener.onDefaultKeyEvent(event);
+ }
+ });
+ }
+
+ @Override // IGeckoEditableParent
+ public void updateCompositionRects(
+ final IBinder token, final RectF[] rects, final RectF caretRect) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ Log.d(LOGTAG, "updateCompositionRects(rects.length = " + rects.length + ")");
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mListener == null) {
+ return;
+ }
+ mListener.updateCompositionRects(rects, caretRect);
+ }
+ });
+ }
+
+ // InvocationHandler interface
+
+ static String getConstantName(final Class<?> cls, final String prefix, final Object value) {
+ for (final Field fld : cls.getDeclaredFields()) {
+ try {
+ if (fld.getName().startsWith(prefix) && fld.get(null).equals(value)) {
+ return fld.getName();
+ }
+ } catch (final IllegalAccessException e) {
+ }
+ }
+ return String.valueOf(value);
+ }
+
+ private static String getPrintableChar(final char chr) {
+ if (chr >= 0x20 && chr <= 0x7e) {
+ return String.valueOf(chr);
+ } else if (chr == '\n') {
+ return "\u21b2";
+ }
+ return String.format("\\u%04x", (int) chr);
+ }
+
+ static StringBuilder debugAppend(final StringBuilder sb, final Object obj) {
+ if (obj == null) {
+ sb.append("null");
+ } else if (obj instanceof GeckoEditable) {
+ sb.append("GeckoEditable");
+ } else if (obj instanceof GeckoEditableChild) {
+ sb.append("GeckoEditableChild");
+ } else if (Proxy.isProxyClass(obj.getClass())) {
+ debugAppend(sb, Proxy.getInvocationHandler(obj));
+ } else if (obj instanceof Character) {
+ sb.append('\'').append(getPrintableChar((Character) obj)).append('\'');
+ } else if (obj instanceof CharSequence) {
+ final String str = obj.toString();
+ sb.append('"');
+ for (int i = 0; i < str.length(); i++) {
+ final char chr = str.charAt(i);
+ if (chr >= 0x20 && chr <= 0x7e) {
+ sb.append(chr);
+ } else {
+ sb.append(getPrintableChar(chr));
+ }
+ }
+ sb.append('"');
+ } else if (obj.getClass().isArray()) {
+ sb.append(obj.getClass().getComponentType().getSimpleName())
+ .append('[')
+ .append(Array.getLength(obj))
+ .append(']');
+ } else {
+ sb.append(obj);
+ }
+ return sb;
+ }
+
+ @Override
+ public Object invoke(final Object proxy, final Method method, final Object[] args)
+ throws Throwable {
+ final Object target;
+ final Class<?> methodInterface = method.getDeclaringClass();
+ if (DEBUG) {
+ // Editable methods should all be called from the IC thread
+ assertOnIcThread();
+ }
+ if (methodInterface == Editable.class
+ || methodInterface == Appendable.class
+ || methodInterface == Spannable.class) {
+ // Method alters the Editable; route calls to our implementation
+ target = this;
+ } else {
+ target = mText.getShadowText();
+ }
+
+ final Object ret = method.invoke(target, args);
+ if (DEBUG) {
+ final StringBuilder log = new StringBuilder(method.getName());
+ log.append("(");
+ if (args != null) {
+ for (final Object arg : args) {
+ debugAppend(log, arg).append(", ");
+ }
+ if (args.length > 0) {
+ log.setLength(log.length() - 2);
+ }
+ }
+ if (method.getReturnType().equals(Void.TYPE)) {
+ log.append(")");
+ } else {
+ debugAppend(log.append(") = "), ret);
+ }
+ Log.d(LOGTAG, log.toString());
+ }
+ return ret;
+ }
+
+ // Spannable interface
+
+ @Override
+ public void removeSpan(final Object what) {
+ if (what == null) {
+ return;
+ }
+
+ if (what == Selection.SELECTION_START || what == Selection.SELECTION_END) {
+ Log.w(LOGTAG, "selection removed with removeSpan()");
+ }
+
+ icOfferAction(Action.newRemoveSpan(what));
+ }
+
+ @Override
+ public void setSpan(final Object what, final int start, final int end, final int flags) {
+ icOfferAction(Action.newSetSpan(what, start, end, flags));
+ }
+
+ // Appendable interface
+
+ @Override
+ public Editable append(final CharSequence text) {
+ return replace(mProxy.length(), mProxy.length(), text, 0, text.length());
+ }
+
+ @Override
+ public Editable append(final CharSequence text, final int start, final int end) {
+ return replace(mProxy.length(), mProxy.length(), text, start, end);
+ }
+
+ @Override
+ public Editable append(final char text) {
+ return replace(mProxy.length(), mProxy.length(), String.valueOf(text), 0, 1);
+ }
+
+ // Editable interface
+
+ @Override
+ public InputFilter[] getFilters() {
+ return mFilters;
+ }
+
+ @Override
+ public void setFilters(final InputFilter[] filters) {
+ mFilters = filters;
+ }
+
+ @Override
+ public void clearSpans() {
+ /* XXX this clears the selection spans too,
+ but there is no way to clear the corresponding selection in Gecko */
+ Log.w(LOGTAG, "selection cleared with clearSpans()");
+ icOfferAction(Action.newRemoveSpan(/* what */ null));
+ }
+
+ @Override
+ public Editable replace(
+ final int st, final int en, final CharSequence source, final int start, final int end) {
+ CharSequence text = source;
+ if (start < 0 || start > end || end > text.length()) {
+ Log.e(
+ LOGTAG,
+ "invalid replace offsets: " + start + " to " + end + ", length: " + text.length());
+ throw new IllegalArgumentException("invalid replace offsets");
+ }
+ if (start != 0 || end != text.length()) {
+ text = text.subSequence(start, end);
+ }
+ if (mFilters != null) {
+ // Filter text before sending the request to Gecko
+ for (int i = 0; i < mFilters.length; ++i) {
+ final CharSequence cs = mFilters[i].filter(text, 0, text.length(), mProxy, st, en);
+ if (cs != null) {
+ text = cs;
+ }
+ }
+ }
+ if (text == source) {
+ // Always create a copy
+ text = new SpannableString(source);
+ }
+ icOfferAction(Action.newReplaceText(text, Math.min(st, en), Math.max(st, en)));
+ return mProxy;
+ }
+
+ @Override
+ public void clear() {
+ replace(0, mProxy.length(), "", 0, 0);
+ }
+
+ @Override
+ public Editable delete(final int st, final int en) {
+ return replace(st, en, "", 0, 0);
+ }
+
+ @Override
+ public Editable insert(final int where, final CharSequence text, final int start, final int end) {
+ return replace(where, where, text, start, end);
+ }
+
+ @Override
+ public Editable insert(final int where, final CharSequence text) {
+ return replace(where, where, text, 0, text.length());
+ }
+
+ @Override
+ public Editable replace(final int st, final int en, final CharSequence text) {
+ return replace(st, en, text, 0, text.length());
+ }
+
+ /* GetChars interface */
+
+ @Override
+ public void getChars(final int start, final int end, final char[] dest, final int destoff) {
+ /* overridden Editable interface methods in GeckoEditable must not be called directly
+ outside of GeckoEditable. Instead, the call must go through mProxy, which ensures
+ that Java is properly synchronized with Gecko */
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ /* Spanned interface */
+
+ @Override
+ public int getSpanEnd(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int getSpanFlags(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int getSpanStart(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public <T> T[] getSpans(final int start, final int end, final Class<T> type) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes") // nextSpanTransition uses raw Class in its Android declaration
+ public int nextSpanTransition(final int start, final int limit, final Class type) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ /* CharSequence interface */
+
+ @Override
+ public char charAt(final int index) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int length() {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public CharSequence subSequence(final int start, final int end) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public String toString() {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ public boolean onKeyPreIme(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return false;
+ }
+
+ public boolean onKeyDown(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return processKey(view, KeyEvent.ACTION_DOWN, keyCode, event);
+ }
+
+ public boolean onKeyUp(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return processKey(view, KeyEvent.ACTION_UP, keyCode, event);
+ }
+
+ public boolean onKeyMultiple(
+ final @Nullable View view,
+ final int keyCode,
+ final int repeatCount,
+ final @NonNull KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_UNKNOWN) {
+ // KEYCODE_UNKNOWN means the characters are in KeyEvent.getCharacters()
+ final String str = event.getCharacters();
+ for (int i = 0; i < str.length(); i++) {
+ final KeyEvent charEvent = getCharKeyEvent(str.charAt(i));
+ if (!processKey(view, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, charEvent)
+ || !processKey(view, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_UNKNOWN, charEvent)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ for (int i = 0; i < repeatCount; i++) {
+ if (!processKey(view, KeyEvent.ACTION_DOWN, keyCode, event)
+ || !processKey(view, KeyEvent.ACTION_UP, keyCode, event)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public boolean onKeyLongPress(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return false;
+ }
+
+ /** Get a key that represents a given character. */
+ private static KeyEvent getCharKeyEvent(final char c) {
+ final long time = SystemClock.uptimeMillis();
+ return new KeyEvent(
+ time, time, KeyEvent.ACTION_MULTIPLE, KeyEvent.KEYCODE_UNKNOWN, /* repeat */ 0) {
+ @Override
+ public int getUnicodeChar() {
+ return c;
+ }
+
+ @Override
+ public int getUnicodeChar(final int metaState) {
+ return c;
+ }
+ };
+ }
+
+ private boolean processKey(
+ final @Nullable View view,
+ final int action,
+ final int keyCode,
+ final @NonNull KeyEvent event) {
+ if (keyCode > KeyEvent.getMaxKeyCode() || !shouldProcessKey(keyCode, event)) {
+ return false;
+ }
+
+ postToInputConnection(
+ new Runnable() {
+ @Override
+ public void run() {
+ sendKeyEvent(view, action, event);
+ }
+ });
+ return true;
+ }
+
+ private static boolean shouldProcessKey(final int keyCode, final KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_MENU:
+ case KeyEvent.KEYCODE_BACK:
+ case KeyEvent.KEYCODE_VOLUME_UP:
+ case KeyEvent.KEYCODE_VOLUME_DOWN:
+ case KeyEvent.KEYCODE_SEARCH:
+ // ignore HEADSETHOOK to allow hold-for-voice-search to work
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ return false;
+ }
+ return true;
+ }
+
+ private static boolean isComposing(final Spanned text) {
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static int getComposingStart(final Spanned text) {
+ int composingStart = Integer.MAX_VALUE;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ composingStart = Math.min(composingStart, text.getSpanStart(span));
+ }
+ }
+
+ return composingStart;
+ }
+
+ private static int getComposingEnd(final Spanned text) {
+ int composingEnd = -1;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ composingEnd = Math.max(composingEnd, text.getSpanEnd(span));
+ }
+ }
+
+ return composingEnd;
+ }
+}