Add IMM#invalidateInput()

Historically TextView#setText() has internally called

  InputMethodManager#restartInput(View)

simply because the text seen from the IME is going to be out-of-sync.

Although this behavior is semantically helpful for IMEs, especially
after the initial surrounding text information became available in
EditorInfo, issuing a sync IPC from the calling thread (UI thread
actually) is not plausible from the performance perspective.

This CL fills this gap by adding a new API

  InputMethodManager#invalidateInput(View)

for the scenario where apps independently modify the text with keeping
other text metadata such as input-type to be the same.

All the observable behaviors from the IME remain to be the same as

  InputMethodManager#restartInput(View).

For instance, any pending tasks that are already issued with

  InputMethodService#getCurrentInputConnection()

will be effectively cancelled by using a recently added mechanism [1].

 [1]: I383c3958d2ac1a8d217706509fa12a92b381bbb3

Fix: 203086369
Test: atest -c CtsInputMethodTestCases:InputMethodStartInputLifecycleTest
Change-Id: I3161755779080f98bcef0e47dd0c5247d8a3a256
This commit is contained in:
Yohei Yukawa
2021-11-17 15:21:32 -08:00
parent 2e9ec744b6
commit daa6695c2e
12 changed files with 298 additions and 10 deletions

View File

@@ -52442,6 +52442,7 @@ package android.view.inputmethod {
method public boolean hideSoftInputFromWindow(android.os.IBinder, int);
method public boolean hideSoftInputFromWindow(android.os.IBinder, int, android.os.ResultReceiver);
method @Deprecated public void hideStatusIcon(android.os.IBinder);
method public void invalidateInput(@NonNull android.view.View);
method public boolean isAcceptingText();
method public boolean isActive(android.view.View);
method public boolean isActive();

View File

@@ -67,6 +67,16 @@ public abstract class AbstractInputMethodService extends WindowProviderService
implements KeyEvent.Callback {
private InputMethod mInputMethod;
/**
* @return {@link InputMethod} instance returned from {@link #onCreateInputMethodInterface()}.
* {@code null} if {@link #onCreateInputMethodInterface()} is not yet called.
* @hide
*/
@Nullable
protected final InputMethod getInputMethodInternal() {
return mInputMethod;
}
/**
* Keep the strong reference to {@link InputMethodServiceInternal} to ensure that it will not be
* garbage-collected until {@link AbstractInputMethodService} gets garbage-collected.

View File

@@ -32,11 +32,13 @@ import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CursorAnchorInfo;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.InputMethodSession;
import com.android.internal.os.HandlerCaller;
import com.android.internal.os.SomeArgs;
import com.android.internal.view.IInputContext;
import com.android.internal.view.IInputMethodSession;
class IInputMethodSessionWrapper extends IInputMethodSession.Stub
@@ -54,6 +56,7 @@ class IInputMethodSessionWrapper extends IInputMethodSession.Stub
private static final int DO_NOTIFY_IME_HIDDEN = 120;
private static final int DO_REMOVE_IME_SURFACE = 130;
private static final int DO_FINISH_INPUT = 140;
private static final int DO_INVALIDATE_INPUT = 150;
@UnsupportedAppUsage
@@ -142,6 +145,16 @@ class IInputMethodSessionWrapper extends IInputMethodSession.Stub
mInputMethodSession.finishInput();
return;
}
case DO_INVALIDATE_INPUT: {
final SomeArgs args = (SomeArgs) msg.obj;
try {
mInputMethodSession.invalidateInputInternal((EditorInfo) args.arg1,
(IInputContext) args.arg2, msg.arg1);
} finally {
args.recycle();
}
return;
}
}
Log.w(TAG, "Unhandled message code: " + msg.what);
}
@@ -217,6 +230,12 @@ class IInputMethodSessionWrapper extends IInputMethodSession.Stub
mCaller.executeOrSendMessage(mCaller.obtainMessage(DO_FINISH_SESSION));
}
@Override
public void invalidateInput(EditorInfo editorInfo, IInputContext inputContext, int sessionId) {
mCaller.executeOrSendMessage(mCaller.obtainMessageIOO(
DO_INVALIDATE_INPUT, sessionId, editorInfo, inputContext));
}
@Override
public void finishInput() {
mCaller.executeOrSendMessage(mCaller.obtainMessage(DO_FINISH_INPUT));

View File

@@ -135,6 +135,7 @@ import com.android.internal.inputmethod.ImeTracing;
import com.android.internal.inputmethod.InputMethodPrivilegedOperations;
import com.android.internal.inputmethod.InputMethodPrivilegedOperationsRegistry;
import com.android.internal.view.IInlineSuggestionsRequestCallback;
import com.android.internal.view.IInputContext;
import com.android.internal.view.InlineSuggestionsRequestInfo;
import java.io.FileDescriptor;
@@ -1063,8 +1064,30 @@ public class InputMethodService extends AbstractInputMethodService {
public final void removeImeSurface() {
InputMethodService.this.scheduleImeSurfaceRemoval();
}
/**
* {@inheritDoc}
* @hide
*/
@Override
public final void invalidateInputInternal(@NonNull EditorInfo editorInfo,
@NonNull IInputContext inputContext, int sessionId) {
if (mStartedInputConnection instanceof RemoteInputConnection) {
final RemoteInputConnection ric = (RemoteInputConnection) mStartedInputConnection;
if (!ric.isSameConnection(inputContext)) {
// This is not an error, and can be safely ignored.
if (DEBUG) {
Log.d(TAG, "ignoring invalidateInput() due to context mismatch.");
}
return;
}
editorInfo.makeCompatible(getApplicationInfo().targetSdkVersion);
getInputMethodInternal().restartInput(new RemoteInputConnection(ric, sessionId),
editorInfo);
}
}
}
/**
* Information about where interesting parts of the input method UI appear.
*/

View File

@@ -103,6 +103,17 @@ final class RemoteInputConnection implements InputConnection {
mCancellationGroup = cancellationGroup;
}
@AnyThread
public boolean isSameConnection(@NonNull IInputContext inputContext) {
return mInvoker.isSameConnection(inputContext);
}
RemoteInputConnection(@NonNull RemoteInputConnection original, int sessionId) {
mImsInternal = original.mImsInternal;
mInvoker = original.mInvoker.cloneWithSessionId(sessionId);
mCancellationGroup = original.mCancellationGroup;
}
/**
* See {@link InputConnection#getTextAfterCursor(int, int)}.
*/

View File

@@ -44,6 +44,7 @@ import android.view.View;
import android.view.autofill.AutofillId;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.ArrayUtils;
import com.android.internal.util.Preconditions;
import java.lang.annotation.Retention;
@@ -584,6 +585,16 @@ public class EditorInfo implements InputType, Parcelable {
setInitialSurroundingSubText(sourceText, /* subTextStart = */ 0);
}
/**
* An internal variant of {@link #setInitialSurroundingText(CharSequence)}.
*
* @param surroundingText {@link SurroundingText} to be set.
* @hide
*/
public final void setInitialSurroundingTextInternal(@NonNull SurroundingText surroundingText) {
mInitialSurroundingText = surroundingText;
}
/**
* Editors may use this method to provide initial input text to IMEs. As the surrounding text
* could be used to provide various input assistance, we recommend editors to provide the
@@ -971,6 +982,35 @@ public class EditorInfo implements InputType, Parcelable {
}
}
/**
* @return A deep copy of {@link EditorInfo}.
* @hide
*/
@NonNull
public final EditorInfo createCopyInternal() {
final EditorInfo newEditorInfo = new EditorInfo();
newEditorInfo.inputType = inputType;
newEditorInfo.imeOptions = imeOptions;
newEditorInfo.privateImeOptions = privateImeOptions;
newEditorInfo.internalImeOptions = internalImeOptions;
newEditorInfo.actionLabel = TextUtils.stringOrSpannedString(actionLabel);
newEditorInfo.actionId = actionId;
newEditorInfo.initialSelStart = initialSelStart;
newEditorInfo.initialSelEnd = initialSelEnd;
newEditorInfo.initialCapsMode = initialCapsMode;
newEditorInfo.hintText = TextUtils.stringOrSpannedString(hintText);
newEditorInfo.label = TextUtils.stringOrSpannedString(label);
newEditorInfo.packageName = packageName;
newEditorInfo.autofillId = autofillId;
newEditorInfo.fieldId = fieldId;
newEditorInfo.fieldName = fieldName;
newEditorInfo.extras = extras != null ? extras.deepCopy() : null;
newEditorInfo.mInitialSurroundingText = mInitialSurroundingText;
newEditorInfo.hintLocales = hintLocales;
newEditorInfo.contentMimeTypes = ArrayUtils.cloneOrNull(contentMimeTypes);
return newEditorInfo;
}
/**
* Used to package this object into a {@link Parcel}.
*

View File

@@ -1837,6 +1837,69 @@ public final class InputMethodManager {
startInputInner(StartInputReason.APP_CALLED_RESTART_INPUT_API, null, 0, 0, 0);
}
/**
* Sends an async signal to the IME to reset the currently served {@link InputConnection}.
*
* @param inputConnection the connection to be invalidated.
* @param textSnapshot {@link TextSnapshot} to be used to update {@link EditorInfo}.
* @param sessionId the session ID to be sent.
* @hide
*/
public void doInvalidateInput(@NonNull RemoteInputConnectionImpl inputConnection,
@NonNull TextSnapshot textSnapshot, int sessionId) {
synchronized (mH) {
if (mServedInputConnection != inputConnection || mCurrentTextBoxAttribute == null) {
// OK to ignore because the calling InputConnection is already abandoned.
return;
}
final EditorInfo editorInfo = mCurrentTextBoxAttribute.createCopyInternal();
editorInfo.initialSelStart = mCursorSelStart = textSnapshot.getSelectionStart();
editorInfo.initialSelEnd = mCursorSelEnd = textSnapshot.getSelectionEnd();
mCursorCandStart = textSnapshot.getCompositionStart();
mCursorCandEnd = textSnapshot.getCompositionEnd();
editorInfo.initialCapsMode = textSnapshot.getCursorCapsMode();
editorInfo.setInitialSurroundingTextInternal(textSnapshot.getSurroundingText());
mCurrentInputMethodSession.invalidateInput(editorInfo, mServedInputConnection,
sessionId);
}
}
/**
* Gives a hint to the system that the text associated with {@code view} is updated by something
* that is not an input method editor (IME), so that the system can cancel any pending text
* editing requests from the IME until it receives the new editing context such as surrounding
* text provided by {@link InputConnection#takeSnapshot()}.
*
* <p>When {@code view} does not support {@link InputConnection#takeSnapshot()} protocol,
* calling this method may trigger {@link View#onCreateInputConnection(EditorInfo)}.</p>
*
* <p>Unlike {@link #restartInput(View)}, this API does not immediately interact with
* {@link InputConnection}. Instead, the application may later receive
* {@link InputConnection#takeSnapshot()} as needed so that the system can capture new editing
* context for the IME. For instance, successive invocations of this API can be coerced into a
* single (or zero) callback of {@link InputConnection#takeSnapshot()}.</p>
*
* @param view The view whose text has changed.
* @see #restartInput(View)
*/
public void invalidateInput(@NonNull View view) {
Objects.requireNonNull(view);
// Re-dispatch if there is a context mismatch.
final InputMethodManager fallbackImm = getFallbackInputMethodManagerIfNecessary(view);
if (fallbackImm != null) {
fallbackImm.invalidateInput(view);
return;
}
synchronized (mH) {
if (mServedInputConnection == null || getServedViewLocked() != view) {
return;
}
mServedInputConnection.scheduleInvalidateInput();
}
}
/**
* Called when {@link DelegateImpl#startInput}, {@link #restartInput(View)},
* {@link #MSG_BIND} or {@link #MSG_UNBIND}.
@@ -1929,7 +1992,7 @@ public final class InputMethodManager {
}
// Hook 'em up and let 'er rip.
mCurrentTextBoxAttribute = tba;
mCurrentTextBoxAttribute = tba.createCopyInternal();
mServedConnecting = false;
if (mServedInputConnection != null) {
@@ -2202,6 +2265,10 @@ public final class InputMethodManager {
return;
}
if (mServedInputConnection != null && mServedInputConnection.hasPendingInvalidation()) {
return;
}
if (mCursorSelStart != selStart || mCursorSelEnd != selEnd
|| mCursorCandStart != candidatesStart
|| mCursorCandEnd != candidatesEnd) {

View File

@@ -18,11 +18,12 @@ package android.view.inputmethod;
import android.graphics.Rect;
import android.inputmethodservice.InputMethodService;
import android.os.Build;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.MotionEvent;
import com.android.internal.view.IInputContext;
/**
* The InputMethodSession interface provides the per-client functionality
* of {@link InputMethod} that is safe to expose to applications.
@@ -175,8 +176,8 @@ public interface InputMethodSession {
* 0 or have the {@link InputMethodManager#HIDE_IMPLICIT_ONLY},
* {@link InputMethodManager#HIDE_NOT_ALWAYS} bit set.
*
* @deprecated Starting in {@link Build.VERSION_CODES#S} the system no longer invokes this
* method, instead it explicitly shows or hides the IME. An {@code InputMethodService}
* @deprecated Starting in {@link android.os.Build.VERSION_CODES#S} the system no longer invokes
* this method, instead it explicitly shows or hides the IME. An {@code InputMethodService}
* wishing to toggle its own visibility should instead invoke {@link
* InputMethodService#requestShowSelf} or {@link InputMethodService#requestHideSelf}
*/
@@ -205,4 +206,20 @@ public interface InputMethodSession {
* @hide
*/
public void removeImeSurface();
/**
* Called when {@code inputContext} is about to be reset with {@code sessionId}.
*
* <p>The actual implementation should ignore if {@code inputContext} is no longer the current
* {@link InputConnection} due to a stale callback.</p>
*
* @param editorInfo {@link EditorInfo} to be used
* @param inputContext specifies which {@link InputConnection} is being updated.
* @param sessionId the ID to be specified to
* {@link com.android.internal.inputmethod.InputConnectionCommandHeader}.
* @hide
*/
default void invalidateInputInternal(EditorInfo editorInfo, IInputContext inputContext,
int sessionId) {
}
}

View File

@@ -24,6 +24,7 @@ import android.os.Bundle;
import android.os.RemoteException;
import android.util.Log;
import com.android.internal.view.IInputContext;
import com.android.internal.view.IInputMethodSession;
/**
@@ -142,6 +143,15 @@ final class InputMethodSessionWrapper {
}
}
@AnyThread
void invalidateInput(EditorInfo editorInfo, IInputContext inputContext, int sessionId) {
try {
mSession.invalidateInput(editorInfo, inputContext, sessionId);
} catch (RemoteException e) {
Log.w(TAG, "IME died", e);
}
}
/**
* @return {@link IInputMethodSession#toString()} as a debug string.
*/

View File

@@ -43,9 +43,11 @@ public final class IInputContextInvoker {
@NonNull
private final IInputContext mIInputContext;
private final int mSessionId;
private IInputContextInvoker(@NonNull IInputContext inputContext) {
private IInputContextInvoker(@NonNull IInputContext inputContext, int sessionId) {
mIInputContext = inputContext;
mSessionId = sessionId;
}
/**
@@ -56,13 +58,36 @@ public final class IInputContextInvoker {
*/
public static IInputContextInvoker create(@NonNull IInputContext inputContext) {
Objects.requireNonNull(inputContext);
return new IInputContextInvoker(inputContext);
return new IInputContextInvoker(inputContext, 0);
}
/**
* Creates a new instance of {@link IInputContextInvoker} with the given {@code sessionId}.
*
* @param sessionId the new session ID to be used.
* @return A new instance of {@link IInputContextInvoker}.
*/
@NonNull
public IInputContextInvoker cloneWithSessionId(int sessionId) {
return new IInputContextInvoker(mIInputContext, sessionId);
}
/**
* @param inputContext {@code IInputContext} to be compared with
* @return {@code true} if the underlying {@code IInputContext} is the same. {@code false} if
* {@code inputContext} is {@code null}.
*/
@AnyThread
public boolean isSameConnection(@NonNull IInputContext inputContext) {
if (inputContext == null) {
return false;
}
return mIInputContext.asBinder() == inputContext.asBinder();
}
@NonNull
InputConnectionCommandHeader createHeader() {
// TODO(b/203086369): Propagate session ID for interruption
return new InputConnectionCommandHeader(0 /* sessionId */);
return new InputConnectionCommandHeader(mSessionId);
}
/**

View File

@@ -43,6 +43,7 @@ import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputContentInfo;
import android.view.inputmethod.InputMethodManager;
import android.view.inputmethod.TextAttribute;
import android.view.inputmethod.TextSnapshot;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.infra.AndroidFuture;
@@ -50,6 +51,7 @@ import com.android.internal.view.IInputContext;
import java.lang.annotation.Retention;
import java.lang.ref.WeakReference;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -87,8 +89,8 @@ public final class RemoteInputConnectionImpl extends IInputContext.Stub {
private final InputMethodManager mParentInputMethodManager;
private final WeakReference<View> mServedView;
// TODO(b/203086369): This is to be used when interruption is implemented.
private final AtomicInteger mCurrentSessionId = new AtomicInteger(0);
private final AtomicBoolean mHasPendingInvalidation = new AtomicBoolean();
public RemoteInputConnectionImpl(@NonNull Looper looper,
@NonNull InputConnection inputConnection,
@@ -110,6 +112,14 @@ public final class RemoteInputConnectionImpl extends IInputContext.Stub {
}
}
/**
* @return {@code true} if there is a pending {@link InputMethodManager#invalidateInput(View)}
* call.
*/
public boolean hasPendingInvalidation() {
return mHasPendingInvalidation.get();
}
/**
* @return {@code true} until the target {@link InputConnection} receives
* {@link InputConnection#closeConnection()} as a result of {@link #deactivate()}.
@@ -128,6 +138,56 @@ public final class RemoteInputConnectionImpl extends IInputContext.Stub {
return mServedView.get();
}
/**
* Schedule a task to execute
* {@link InputMethodManager#doInvalidateInput(RemoteInputConnectionImpl, TextSnapshot, int)}
* on the associated Handler if not yet scheduled.
*
* <p>By calling {@link InputConnection#takeSnapshot()} directly from the message loop, we can
* make sure that application code is not modifying text context in a reentrant manner.</p>
*/
public void scheduleInvalidateInput() {
if (mHasPendingInvalidation.compareAndSet(false, true)) {
final int nextSessionId = mCurrentSessionId.incrementAndGet();
// By calling InputConnection#takeSnapshot() directly from the message loop, we can make
// sure that application code is not modifying text context in a reentrant manner.
// e.g. We may see methods like EditText#setText() in the callstack here.
mH.post(() -> {
try {
if (isFinished()) {
return;
}
final InputConnection ic = getInputConnection();
if (ic == null) {
return;
}
// Clean up composing text and batch edit.
ic.finishComposingText();
// Also clean up batch edit.
while (true) {
if (!ic.endBatchEdit()) {
break;
}
}
final TextSnapshot textSnapshot = ic.takeSnapshot();
if (textSnapshot == null) {
final View view = getServedView();
if (view == null) {
return;
}
mParentInputMethodManager.restartInput(view);
return;
}
mParentInputMethodManager.doInvalidateInput(this, textSnapshot, nextSessionId);
} finally {
mHasPendingInvalidation.set(false);
}
});
}
}
/**
* Called when this object needs to be permanently deactivated.
*

View File

@@ -22,8 +22,11 @@ import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CursorAnchorInfo;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import com.android.internal.view.IInputContext;
/**
* Sub-interface of IInputMethod which is safe to give to client applications.
* {@hide}
@@ -52,4 +55,6 @@ oneway interface IInputMethodSession {
void removeImeSurface();
void finishInput();
void invalidateInput(in EditorInfo editorInfo, in IInputContext inputContext, int sessionId);
}