From d5d530ad63dbabc92125213b4823235b0edc6aa9 Mon Sep 17 00:00:00 2001 From: Lan Wei Date: Mon, 29 Aug 2022 15:20:58 +0800 Subject: [PATCH] IMA API InputConnection#replaceText() Add InputConnecton#replaceText(int, int, CharSequence, int, TextAttribute) API to support replacing the specific range in the editor with suggested text. Add default implementation in InputConnection which delegates to call finishComposingText()+setSelection()+commitText(). Add implementation in BaseInputConnection which will address the issue of selection flash. Bug: 241503197 Test: atest FrameworksCoreTests:BaseInputConnectionTest Test: atest CtsInputMethodTestCases:BaseInputConnectionTest Change-Id: Ia0e42270cfae112fcf19205a7151e4ab4ac4afdd --- core/api/current.txt | 1 + .../IRemoteInputConnectionInvoker.java | 29 ++++++++ .../RemoteInputConnection.java | 11 +++ .../view/inputmethod/BaseInputConnection.java | 52 ++++++++++++-- .../view/inputmethod/InputConnection.java | 40 +++++++++++ .../inputmethod/IRemoteInputConnection.aidl | 3 + .../RemoteInputConnectionImpl.java | 24 +++++++ .../inputmethod/BaseInputConnectionTest.java | 68 +++++++++++++++++++ 8 files changed, 223 insertions(+), 5 deletions(-) diff --git a/core/api/current.txt b/core/api/current.txt index 19713cfebf6c6..578aa8ed36ef1 100644 --- a/core/api/current.txt +++ b/core/api/current.txt @@ -53291,6 +53291,7 @@ package android.view.inputmethod { method public default void performHandwritingGesture(@NonNull android.view.inputmethod.HandwritingGesture, @Nullable java.util.concurrent.Executor, @Nullable java.util.function.IntConsumer); method public boolean performPrivateCommand(String, android.os.Bundle); method public default boolean performSpellCheck(); + method public default boolean replaceText(@IntRange(from=0) int, @IntRange(from=0) int, @NonNull CharSequence, int, @Nullable android.view.inputmethod.TextAttribute); method public boolean reportFullscreenMode(boolean); method public boolean requestCursorUpdates(int); method public default boolean requestCursorUpdates(int, int); diff --git a/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java b/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java index 6f758de63c95e..891da2466bffa 100644 --- a/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java +++ b/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java @@ -775,4 +775,33 @@ final class IRemoteInputConnectionInvoker { return false; } } + + /** + * Invokes {@link IRemoteInputConnection#replaceText(InputConnectionCommandHeader, int, int, + * CharSequence, TextAttribute)}. + * + * @param start the character index where the replacement should start. + * @param end the character index where the replacement should end. + * @param newCursorPosition the new cursor position around the text. If > 0, this is relative to + * the end of the text - 1; if <= 0, this is relative to the start of the text. So a value + * of 1 will always advance you to the position after the full text being inserted. Note + * that this means you can't position the cursor within the text. + * @param text the text to replace. This may include styles. + * @param textAttribute The extra information about the text. This value may be null. + */ + @AnyThread + public boolean replaceText( + int start, + int end, + @NonNull CharSequence text, + int newCursorPosition, + @Nullable TextAttribute textAttribute) { + try { + mConnection.replaceText( + createHeader(), start, end, text, newCursorPosition, textAttribute); + return true; + } catch (RemoteException e) { + return false; + } + } } diff --git a/core/java/android/inputmethodservice/RemoteInputConnection.java b/core/java/android/inputmethodservice/RemoteInputConnection.java index 694293c62bd73..2b5f14d530e8d 100644 --- a/core/java/android/inputmethodservice/RemoteInputConnection.java +++ b/core/java/android/inputmethodservice/RemoteInputConnection.java @@ -498,6 +498,17 @@ final class RemoteInputConnection implements InputConnection { return mInvoker.setImeConsumesInput(imeConsumesInput); } + /** See {@link InputConnection#replaceText(int, int, CharSequence, int, TextAttribute)}. */ + @AnyThread + public boolean replaceText( + int start, + int end, + @NonNull CharSequence text, + int newCursorPosition, + @Nullable TextAttribute textAttribute) { + return mInvoker.replaceText(start, end, text, newCursorPosition, textAttribute); + } + @AnyThread @Override public String toString() { diff --git a/core/java/android/view/inputmethod/BaseInputConnection.java b/core/java/android/view/inputmethod/BaseInputConnection.java index a72f0d50a3a9b..3733c3f2efcc7 100644 --- a/core/java/android/view/inputmethod/BaseInputConnection.java +++ b/core/java/android/view/inputmethod/BaseInputConnection.java @@ -897,8 +897,43 @@ public class BaseInputConnection implements InputConnection { } } - private void replaceText(CharSequence text, int newCursorPosition, - boolean composing) { + @Override + public boolean replaceText( + @IntRange(from = 0) int start, + @IntRange(from = 0) int end, + @NonNull CharSequence text, + int newCursorPosition, + @Nullable TextAttribute textAttribute) { + Preconditions.checkArgumentNonnegative(start); + Preconditions.checkArgumentNonnegative(end); + + if (DEBUG) { + Log.v( + TAG, + "replaceText " + start + ", " + end + ", " + text + ", " + newCursorPosition); + } + + final Editable content = getEditable(); + if (content == null) { + return false; + } + beginBatchEdit(); + removeComposingSpans(content); + + int len = content.length(); + start = Math.min(start, len); + end = Math.min(end, len); + if (end < start) { + int tmp = start; + start = end; + end = tmp; + } + replaceTextInternal(start, end, text, newCursorPosition, /*composing=*/ false); + endBatchEdit(); + return true; + } + + private void replaceText(CharSequence text, int newCursorPosition, boolean composing) { final Editable content = getEditable(); if (content == null) { return; @@ -931,6 +966,16 @@ public class BaseInputConnection implements InputConnection { b = tmp; } } + replaceTextInternal(a, b, text, newCursorPosition, composing); + endBatchEdit(); + } + + private void replaceTextInternal( + int a, int b, CharSequence text, int newCursorPosition, boolean composing) { + final Editable content = getEditable(); + if (content == null) { + return; + } if (composing) { Spannable sp = null; @@ -974,7 +1019,6 @@ public class BaseInputConnection implements InputConnection { if (newCursorPosition < 0) newCursorPosition = 0; if (newCursorPosition > content.length()) newCursorPosition = content.length(); Selection.setSelection(content, newCursorPosition); - content.replace(a, b, text); if (DEBUG) { @@ -982,8 +1026,6 @@ public class BaseInputConnection implements InputConnection { lp.println("Final text:"); TextUtils.dumpSpans(content, lp, " "); } - - endBatchEdit(); } /** diff --git a/core/java/android/view/inputmethod/InputConnection.java b/core/java/android/view/inputmethod/InputConnection.java index 2f834c9e819f3..7d268a925f60e 100644 --- a/core/java/android/view/inputmethod/InputConnection.java +++ b/core/java/android/view/inputmethod/InputConnection.java @@ -1329,4 +1329,44 @@ public interface InputConnection { // existing APIs. return null; } + + /** + * Replace the specific range in the editor with suggested text. + * + *

This method finishes whatever composing text is currently active and leaves the text + * as-it, replaces the specific range of text with the passed CharSequence, and then moves the + * cursor according to {@code newCursorPosition}. This behaves like calling {@link + * #finishComposingText()}, {@link #setSelection(int, int) setSelection(start, end)}, and then + * {@link #commitText(CharSequence, int, TextAttribute) commitText(text, newCursorPosition, + * textAttribute)}. + * + *

Similar to {@link #setSelection(int, int)}, the order of start and end is not important. + * In effect, the region from start to end and the region from end to start is the same. Editor + * authors, be ready to accept a start that is greater than end. + * + * @param start the character index where the replacement should start. + * @param end the character index where the replacement should end. + * @param newCursorPosition the new cursor position around the text. If > 0, this is relative to + * the end of the text - 1; if <= 0, this is relative to the start of the text. So a value + * of 1 will always advance you to the position after the full text being inserted. Note + * that this means you can't position the cursor within the text. + * @param text the text to replace. This may include styles. + * @param textAttribute The extra information about the text. This value may be null. + */ + default boolean replaceText( + @IntRange(from = 0) int start, + @IntRange(from = 0) int end, + @NonNull CharSequence text, + int newCursorPosition, + @Nullable TextAttribute textAttribute) { + Preconditions.checkArgumentNonnegative(start); + Preconditions.checkArgumentNonnegative(end); + + beginBatchEdit(); + finishComposingText(); + setSelection(start, end); + commitText(text, newCursorPosition, textAttribute); + endBatchEdit(); + return true; + } } diff --git a/core/java/com/android/internal/inputmethod/IRemoteInputConnection.aidl b/core/java/com/android/internal/inputmethod/IRemoteInputConnection.aidl index 17f9b7dea0459..ea5c9a33b7623 100644 --- a/core/java/com/android/internal/inputmethod/IRemoteInputConnection.aidl +++ b/core/java/com/android/internal/inputmethod/IRemoteInputConnection.aidl @@ -137,4 +137,7 @@ import com.android.internal.inputmethod.InputConnectionCommandHeader; int afterLength, int flags, in AndroidFuture future /* T=SurroundingText */); void setImeConsumesInput(in InputConnectionCommandHeader header, boolean imeConsumesInput); + + void replaceText(in InputConnectionCommandHeader header, int start, int end, CharSequence text, + int newCursorPosition,in TextAttribute textAttribute); } diff --git a/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java b/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java index 713e913abce10..fcaa1e1c330e4 100644 --- a/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java +++ b/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java @@ -1185,6 +1185,30 @@ public final class RemoteInputConnectionImpl extends IRemoteInputConnection.Stub }); } + @Dispatching(cancellable = true) + @Override + public void replaceText( + InputConnectionCommandHeader header, + int start, + int end, + @NonNull CharSequence text, + int newCursorPosition, + @Nullable TextAttribute textAttribute) { + dispatchWithTracing( + "replaceText", + () -> { + if (header.mSessionId != mCurrentSessionId.get()) { + return; // cancelled + } + InputConnection ic = getInputConnection(); + if (ic == null || !isActive()) { + Log.w(TAG, "replaceText on inactive InputConnection"); + return; + } + ic.replaceText(start, end, text, newCursorPosition, textAttribute); + }); + } + private final IRemoteAccessibilityInputConnection mAccessibilityInputConnection = new IRemoteAccessibilityInputConnection.Stub() { @Dispatching(cancellable = true) diff --git a/core/tests/coretests/src/android/view/inputmethod/BaseInputConnectionTest.java b/core/tests/coretests/src/android/view/inputmethod/BaseInputConnectionTest.java index 2bb5abefd304e..4007c43e59444 100644 --- a/core/tests/coretests/src/android/view/inputmethod/BaseInputConnectionTest.java +++ b/core/tests/coretests/src/android/view/inputmethod/BaseInputConnectionTest.java @@ -619,6 +619,74 @@ public class BaseInputConnectionTest { verifyTextSnapshotContentEquals(mBaseInputConnection.takeSnapshot(), expectedTextSnapshot); } + @Test + public void testReplaceText_toEditorWithoutSelectionAndComposing() { + // before replace: "|" + // after replace: "text1|" + assertThat(mBaseInputConnection.replaceText(0, 0, "text1", 1, null)).isTrue(); + verifyContent("text1", 5, 5, -1, -1); + + // before replace: "text1|" + // after replace: "text2|" + assertThat(mBaseInputConnection.replaceText(0, 5, "text2", 1, null)).isTrue(); + verifyContent("text2", 5, 5, -1, -1); + + // before replace: "text1|" + // after replace: "|text3" + assertThat(mBaseInputConnection.replaceText(0, 5, "text3", -1, null)).isTrue(); + verifyContent("text3", 0, 0, -1, -1); + + // before replace: "|text3" + // after replace: "ttext4|t3" + // BUG(b/21476564): this behavior is inconsistent with API description. + assertThat(mBaseInputConnection.replaceText(1, 3, "text4", 1, null)).isTrue(); + verifyContent("ttext4t3", 6, 6, -1, -1); + + // before replace: "ttext4|t3" + // after replace: "|text5t3" + assertThat(mBaseInputConnection.replaceText(0, 6, "text5", -1, null)).isTrue(); + verifyContent("text5t3", 0, 0, -1, -1); + } + + @Test + public void testReplaceText_toEditorWithSelection() { + // before replace: "123|456|789" + // before replace: "123text|6789" + prepareContent("123456789", 3, 6, -1, -1); + assertThat(mBaseInputConnection.replaceText(3, 5, "text", 1, null)).isTrue(); + verifyContent("123text6789", 7, 7, -1, -1); + + // before replace: "|123|" + // before replace: "|text23" + prepareContent("123", 0, 3, -1, -1); + assertThat(mBaseInputConnection.replaceText(0, 1, "text", 0, null)).isTrue(); + verifyContent("text23", 0, 0, -1, -1); + } + + @Test + public void testReplaceText_toEditorWithComposing() { + // before replace: "123456|789" + // --- + // before replace: "123456text|" + prepareContent("123456789", 6, 6, 3, 6); + assertThat(mBaseInputConnection.replaceText(6, 9, "text", 1, null)).isTrue(); + verifyContent("123456text", 10, 10, -1, -1); + + // before replace: "123456789|" + // --- + // before replace: "text|123456789" + prepareContent("123456789", 9, 9, 3, 6); + assertThat(mBaseInputConnection.replaceText(0, 0, "text", 1, null)).isTrue(); + verifyContent("text123456789", 4, 4, -1, -1); + + // before replace: "|123456789|" + // --- + // before replace: "12text|9" + prepareContent("123456789", 0, 9, 3, 6); + assertThat(mBaseInputConnection.replaceText(2, 8, "text", 1, null)).isTrue(); + verifyContent("12text9", 6, 6, -1, -1); + } + private void prepareContent( CharSequence text, int selectionStart,