diff options
10 files changed, 1344 insertions, 0 deletions
diff --git a/core/api/current.txt b/core/api/current.txt index 2ab5b4932396..79a562a0e99c 100644 --- a/core/api/current.txt +++ b/core/api/current.txt @@ -45561,6 +45561,14 @@ package android.text { field public static final int DONE = -1; // 0xffffffff } + public static class SegmentFinder.DefaultSegmentFinder extends android.text.SegmentFinder { + ctor public SegmentFinder.DefaultSegmentFinder(@NonNull int[]); + method public int nextEndBoundary(@IntRange(from=0) int); + method public int nextStartBoundary(@IntRange(from=0) int); + method public int previousEndBoundary(@IntRange(from=0) int); + method public int previousStartBoundary(@IntRange(from=0) int); + } + public class Selection { method public static boolean extendDown(android.text.Spannable, android.text.Layout); method public static boolean extendLeft(android.text.Spannable, android.text.Layout); @@ -53568,6 +53576,7 @@ package android.view.inputmethod { method public boolean reportFullscreenMode(boolean); method public boolean requestCursorUpdates(int); method public default boolean requestCursorUpdates(int, int); + method public default void requestTextBoundsInfo(@NonNull android.graphics.RectF, @NonNull java.util.concurrent.Executor, @NonNull java.util.function.Consumer<android.view.inputmethod.TextBoundsInfoResult>); method public boolean sendKeyEvent(android.view.KeyEvent); method public boolean setComposingRegion(int, int); method public default boolean setComposingRegion(int, int, @Nullable android.view.inputmethod.TextAttribute); @@ -53897,6 +53906,50 @@ package android.view.inputmethod { method @NonNull public android.view.inputmethod.TextAttribute.Builder setTextConversionSuggestions(@NonNull java.util.List<java.lang.String>); } + public final class TextBoundsInfo implements android.os.Parcelable { + method public int describeContents(); + method @IntRange(from=0, to=125) public int getCharacterBidiLevel(int); + method @NonNull public android.graphics.RectF getCharacterBounds(int); + method public int getCharacterFlags(int); + method public int getEnd(); + method @NonNull public android.text.SegmentFinder getGraphemeSegmentFinder(); + method @NonNull public android.text.SegmentFinder getLineSegmentFinder(); + method @NonNull public android.graphics.Matrix getMatrix(); + method public int getStart(); + method @NonNull public android.text.SegmentFinder getWordSegmentFinder(); + method public void writeToParcel(@NonNull android.os.Parcel, int); + field @NonNull public static final android.os.Parcelable.Creator<android.view.inputmethod.TextBoundsInfo> CREATOR; + field public static final int FLAG_CHARACTER_LINEFEED = 2; // 0x2 + field public static final int FLAG_CHARACTER_PUNCTUATION = 4; // 0x4 + field public static final int FLAG_CHARACTER_WHITESPACE = 1; // 0x1 + field public static final int FLAG_LINE_IS_RTL = 8; // 0x8 + } + + public static final class TextBoundsInfo.Builder { + ctor public TextBoundsInfo.Builder(); + method @NonNull public android.view.inputmethod.TextBoundsInfo build(); + method @NonNull public android.view.inputmethod.TextBoundsInfo.Builder clear(); + method @NonNull public android.view.inputmethod.TextBoundsInfo.Builder setCharacterBidiLevel(@NonNull int[]); + method @NonNull public android.view.inputmethod.TextBoundsInfo.Builder setCharacterBounds(@NonNull float[]); + method @NonNull public android.view.inputmethod.TextBoundsInfo.Builder setCharacterFlags(@NonNull int[]); + method @NonNull public android.view.inputmethod.TextBoundsInfo.Builder setGraphemeSegmentFinder(@NonNull android.text.SegmentFinder); + method @NonNull public android.view.inputmethod.TextBoundsInfo.Builder setLineSegmentFinder(@NonNull android.text.SegmentFinder); + method @NonNull public android.view.inputmethod.TextBoundsInfo.Builder setMatrix(@NonNull android.graphics.Matrix); + method @NonNull public android.view.inputmethod.TextBoundsInfo.Builder setStartAndEnd(@IntRange(from=0) int, @IntRange(from=0) int); + method @NonNull public android.view.inputmethod.TextBoundsInfo.Builder setWordSegmentFinder(@NonNull android.text.SegmentFinder); + } + + public final class TextBoundsInfoResult { + ctor public TextBoundsInfoResult(int); + ctor public TextBoundsInfoResult(int, @NonNull android.view.inputmethod.TextBoundsInfo); + method public int getResultCode(); + method @Nullable public android.view.inputmethod.TextBoundsInfo getTextBoundsInfo(); + field public static final int CODE_CANCELLED = 3; // 0x3 + field public static final int CODE_FAILED = 2; // 0x2 + field public static final int CODE_SUCCESS = 1; // 0x1 + field public static final int CODE_UNSUPPORTED = 0; // 0x0 + } + public final class TextSnapshot { ctor public TextSnapshot(@NonNull android.view.inputmethod.SurroundingText, @IntRange(from=0xffffffff) int, @IntRange(from=0xffffffff) int, int); method @IntRange(from=0xffffffff) public int getCompositionEnd(); diff --git a/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java b/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java index 4f09beec81dd..9533a01a7732 100644 --- a/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java +++ b/core/java/android/inputmethodservice/IRemoteInputConnectionInvoker.java @@ -16,10 +16,13 @@ package android.inputmethodservice; +import static android.view.inputmethod.TextBoundsInfoResult.CODE_CANCELLED; + import android.annotation.AnyThread; import android.annotation.CallbackExecutor; import android.annotation.NonNull; import android.annotation.Nullable; +import android.graphics.RectF; import android.os.Bundle; import android.os.RemoteException; import android.os.ResultReceiver; @@ -40,6 +43,8 @@ import android.view.inputmethod.SelectGesture; import android.view.inputmethod.SelectRangeGesture; import android.view.inputmethod.SurroundingText; import android.view.inputmethod.TextAttribute; +import android.view.inputmethod.TextBoundsInfo; +import android.view.inputmethod.TextBoundsInfoResult; import com.android.internal.infra.AndroidFuture; import com.android.internal.inputmethod.IRemoteInputConnection; @@ -47,6 +52,7 @@ import com.android.internal.inputmethod.InputConnectionCommandHeader; import java.util.Objects; import java.util.concurrent.Executor; +import java.util.function.Consumer; import java.util.function.IntConsumer; /** @@ -98,6 +104,44 @@ final class IRemoteInputConnectionInvoker { }; /** + * Subclass of {@link ResultReceiver} used by + * {@link #requestTextBoundsInfo(RectF, Executor, Consumer)} for providing + * callback. + */ + private static final class TextBoundsInfoResultReceiver extends ResultReceiver { + @Nullable + private Consumer<TextBoundsInfoResult> mConsumer; + @Nullable + private Executor mExecutor; + + TextBoundsInfoResultReceiver(@NonNull Executor executor, + @NonNull Consumer<TextBoundsInfoResult> consumer) { + super(null); + mExecutor = executor; + mConsumer = consumer; + } + + @Override + protected void onReceiveResult(@TextBoundsInfoResult.ResultCode int resultCode, + @Nullable Bundle resultData) { + synchronized (this) { + if (mExecutor != null && mConsumer != null) { + final TextBoundsInfoResult textBoundsInfoResult = new TextBoundsInfoResult( + resultCode, TextBoundsInfo.createFromBundle(resultData)); + mExecutor.execute(() -> mConsumer.accept(textBoundsInfoResult)); + // provide callback only once. + clear(); + } + } + } + + private void clear() { + mExecutor = null; + mConsumer = null; + } + } + + /** * Creates a new instance of {@link IRemoteInputConnectionInvoker} for the given * {@link IRemoteInputConnection}. * @@ -736,6 +780,28 @@ final class IRemoteInputConnectionInvoker { } /** + * Invokes {@link IRemoteInputConnection#requestTextBoundsInfo(InputConnectionCommandHeader, + * RectF, ResultReceiver)} + * @param rectF {@code rectF} parameter to be passed. + * @param executor {@code Executor} parameter to be passed. + * @param consumer {@code Consumer} parameter to be passed. + */ + @AnyThread + public void requestTextBoundsInfo( + @NonNull RectF rectF, @NonNull @CallbackExecutor Executor executor, + @NonNull Consumer<TextBoundsInfoResult> consumer) { + Objects.requireNonNull(executor); + Objects.requireNonNull(consumer); + + final ResultReceiver resultReceiver = new TextBoundsInfoResultReceiver(executor, consumer); + try { + mConnection.requestTextBoundsInfo(createHeader(), rectF, resultReceiver); + } catch (RemoteException e) { + executor.execute(() -> consumer.accept(new TextBoundsInfoResult(CODE_CANCELLED))); + } + } + + /** * Invokes {@link IRemoteInputConnection#commitContent(InputConnectionCommandHeader, * InputContentInfo, int, Bundle, AndroidFuture)}. * diff --git a/core/java/android/inputmethodservice/RemoteInputConnection.java b/core/java/android/inputmethodservice/RemoteInputConnection.java index 09e86c4c9650..9fa87de5f680 100644 --- a/core/java/android/inputmethodservice/RemoteInputConnection.java +++ b/core/java/android/inputmethodservice/RemoteInputConnection.java @@ -21,6 +21,7 @@ import android.annotation.CallbackExecutor; import android.annotation.IntRange; import android.annotation.NonNull; import android.annotation.Nullable; +import android.graphics.RectF; import android.os.Bundle; import android.os.Handler; import android.util.Log; @@ -34,6 +35,7 @@ import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputContentInfo; import android.view.inputmethod.SurroundingText; import android.view.inputmethod.TextAttribute; +import android.view.inputmethod.TextBoundsInfoResult; import com.android.internal.inputmethod.CancellationGroup; import com.android.internal.inputmethod.CompletableFutureUtil; @@ -44,6 +46,7 @@ import com.android.internal.inputmethod.InputConnectionProtoDumper; import java.lang.ref.WeakReference; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import java.util.function.Consumer; import java.util.function.IntConsumer; /** @@ -460,6 +463,13 @@ final class RemoteInputConnection implements InputConnection { } @AnyThread + public void requestTextBoundsInfo( + @NonNull RectF rectF, @NonNull @CallbackExecutor Executor executor, + @NonNull Consumer<TextBoundsInfoResult> consumer) { + mInvoker.requestTextBoundsInfo(rectF, executor, consumer); + } + + @AnyThread public Handler getHandler() { // Nothing should happen when called from input method. return null; diff --git a/core/java/android/text/SegmentFinder.java b/core/java/android/text/SegmentFinder.java index c21c5774fa0a..be0094b28509 100644 --- a/core/java/android/text/SegmentFinder.java +++ b/core/java/android/text/SegmentFinder.java @@ -19,6 +19,13 @@ package android.text; import android.annotation.IntRange; import android.graphics.RectF; +import androidx.annotation.NonNull; + +import com.android.internal.util.Preconditions; + +import java.util.Arrays; +import java.util.Objects; + /** * Finds text segment boundaries within text. Subclasses can implement different types of text * segments. Grapheme clusters and words are examples of possible text segments. These are @@ -63,4 +70,144 @@ public abstract class SegmentFinder { * character offset, or {@code DONE} if there are none. */ public abstract int nextEndBoundary(@IntRange(from = 0) int offset); + + /** + * The default {@link SegmentFinder} implementation based on given segment ranges. + */ + public static class DefaultSegmentFinder extends SegmentFinder { + private final int[] mSegments; + + /** + * Create a SegmentFinder with segments stored in an array, where i-th segment's start is + * stored at segments[2 * i] and end is stored at segments[2 * i + 1] respectively. + * + * <p> It is required that segments do not overlap, and are already sorted by their start + * indices. </p> + * @param segments the array that stores the segment ranges. + * @throws IllegalArgumentException if the given segments array's length is not even; the + * given segments are not sorted or there are segments overlap with others. + */ + public DefaultSegmentFinder(@NonNull int[] segments) { + checkSegmentsValid(segments); + mSegments = segments; + } + + /** {@inheritDoc} */ + @Override + public int previousStartBoundary(@IntRange(from = 0) int offset) { + return findPrevious(offset, /* isStart = */ true); + } + + /** {@inheritDoc} */ + @Override + public int previousEndBoundary(@IntRange(from = 0) int offset) { + return findPrevious(offset, /* isStart = */ false); + } + + /** {@inheritDoc} */ + @Override + public int nextStartBoundary(@IntRange(from = 0) int offset) { + return findNext(offset, /* isStart = */ true); + } + + /** {@inheritDoc} */ + @Override + public int nextEndBoundary(@IntRange(from = 0) int offset) { + return findNext(offset, /* isStart = */ false); + } + + private int findNext(int offset, boolean isStart) { + if (offset < 0) return DONE; + if (mSegments.length < 1 || offset > mSegments[mSegments.length - 1]) return DONE; + + if (offset < mSegments[0]) { + return isStart ? mSegments[0] : mSegments[1]; + } + + int index = Arrays.binarySearch(mSegments, offset); + if (index >= 0) { + // mSegments may have duplicate elements (The previous segments end equals + // to the following segments start.) Move the index forwards since we are searching + // for the next segment. + if (index + 1 < mSegments.length && mSegments[index + 1] == offset) { + index = index + 1; + } + // Point the index to the first segment boundary larger than the given offset. + index += 1; + } else { + // binarySearch returns the insertion point, it's the first segment boundary larger + // than the given offset. + index = -(index + 1); + } + if (index >= mSegments.length) return DONE; + + // +---------------------------------------+ + // | | isStart | isEnd | + // |---------------+-----------+-----------| + // | indexIsStart | index | index + 1 | + // |---------------+-----------+-----------| + // | indexIsEnd | index + 1 | index | + // +---------------------------------------+ + boolean indexIsStart = index % 2 == 0; + if (isStart != indexIsStart) { + return (index + 1 < mSegments.length) ? mSegments[index + 1] : DONE; + } + return mSegments[index]; + } + + private int findPrevious(int offset, boolean isStart) { + if (mSegments.length < 1 || offset < mSegments[0]) return DONE; + + if (offset > mSegments[mSegments.length - 1]) { + return isStart ? mSegments[mSegments.length - 2] : mSegments[mSegments.length - 1]; + } + + int index = Arrays.binarySearch(mSegments, offset); + if (index >= 0) { + // mSegments may have duplicate elements (when the previous segments end equal + // to the following segments start). Move the index backwards since we are searching + // for the previous segment. + if (index > 0 && mSegments[index - 1] == offset) { + index = index - 1; + } + // Point the index to the first segment boundary smaller than the given offset. + index -= 1; + } else { + // binarySearch returns the insertion point, insertionPoint - 1 is the first + // segment boundary smaller than the given offset. + index = -(index + 1) - 1; + } + if (index < 0) return DONE; + + // +---------------------------------------+ + // | | isStart | isEnd | + // |---------------+-----------+-----------| + // | indexIsStart | index | index - 1 | + // |---------------+-----------+-----------| + // | indexIsEnd | index - 1 | index | + // +---------------------------------------+ + boolean indexIsStart = index % 2 == 0; + if (isStart != indexIsStart) { + return (index > 0) ? mSegments[index - 1] : DONE; + } + return mSegments[index]; + } + + private static void checkSegmentsValid(int[] segments) { + Objects.requireNonNull(segments); + Preconditions.checkArgument(segments.length % 2 == 0, + "the length of segments must be even"); + if (segments.length == 0) return; + int lastSegmentEnd = Integer.MIN_VALUE; + for (int index = 0; index < segments.length; index += 2) { + if (segments[index] < lastSegmentEnd) { + throw new IllegalArgumentException("segments can't overlap"); + } + if (segments[index] >= segments[index + 1]) { + throw new IllegalArgumentException("the segment range can't be empty"); + } + lastSegmentEnd = segments[index + 1]; + } + } + } } diff --git a/core/java/android/view/inputmethod/InputConnection.java b/core/java/android/view/inputmethod/InputConnection.java index 7d268a925f60..d6d7339b602c 100644 --- a/core/java/android/view/inputmethod/InputConnection.java +++ b/core/java/android/view/inputmethod/InputConnection.java @@ -16,11 +16,14 @@ package android.view.inputmethod; +import static android.view.inputmethod.TextBoundsInfoResult.CODE_UNSUPPORTED; + import android.annotation.CallbackExecutor; import android.annotation.IntDef; import android.annotation.IntRange; import android.annotation.NonNull; import android.annotation.Nullable; +import android.graphics.RectF; import android.inputmethodservice.InputMethodService; import android.os.Bundle; import android.os.Handler; @@ -32,7 +35,9 @@ import com.android.internal.util.Preconditions; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.Objects; import java.util.concurrent.Executor; +import java.util.function.Consumer; import java.util.function.IntConsumer; /** @@ -1205,6 +1210,40 @@ public interface InputConnection { return false; } + + /** + * Called by input method to request the {@link TextBoundsInfo} for a range of text which is + * covered by or in vicinity of the given {@code RectF}. It can be used as a supplementary + * method to implement the handwriting gesture API - + * {@link #performHandwritingGesture(HandwritingGesture, Executor, IntConsumer)}. + * + * <p><strong>Editor authors</strong>: It's preferred that the editor returns a + * {@link TextBoundsInfo} of all the text lines whose bounds intersect with the given + * {@code rectF}. + * </p> + * + * <p><strong>IME authors</strong>: This method is expensive when the text is long. Please + * consider that both the text bounds computation and IPC round-trip to send the data are time + * consuming. It's preferable to only request text bounds in smaller areas. + * </p> + * + * @param rectF the interested area where the text bounds are requested, in the screen + * coordinates. + * @param executor the executor to run the callback. + * @param consumer the callback invoked by editor to return the result. It must return a + * non-null object. + * + * @see TextBoundsInfo + * @see android.view.inputmethod.TextBoundsInfoResult + */ + default void requestTextBoundsInfo( + @NonNull RectF rectF, @NonNull @CallbackExecutor Executor executor, + @NonNull Consumer<TextBoundsInfoResult> consumer) { + Objects.requireNonNull(executor); + Objects.requireNonNull(consumer); + executor.execute(() -> consumer.accept(new TextBoundsInfoResult(CODE_UNSUPPORTED))); + } + /** * Called by the system to enable application developers to specify a dedicated thread on which * {@link InputConnection} methods are called back. diff --git a/core/java/android/view/inputmethod/InputConnectionWrapper.java b/core/java/android/view/inputmethod/InputConnectionWrapper.java index 56beddf2ef38..7af96b60938c 100644 --- a/core/java/android/view/inputmethod/InputConnectionWrapper.java +++ b/core/java/android/view/inputmethod/InputConnectionWrapper.java @@ -20,6 +20,7 @@ import android.annotation.CallbackExecutor; import android.annotation.IntRange; import android.annotation.NonNull; import android.annotation.Nullable; +import android.graphics.RectF; import android.os.Bundle; import android.os.Handler; import android.view.KeyEvent; @@ -27,6 +28,7 @@ import android.view.KeyEvent; import com.android.internal.util.Preconditions; import java.util.concurrent.Executor; +import java.util.function.Consumer; import java.util.function.IntConsumer; /** @@ -347,6 +349,17 @@ public class InputConnectionWrapper implements InputConnection { * @throws NullPointerException if the target is {@code null}. */ @Override + public void requestTextBoundsInfo( + @NonNull RectF rectF, @NonNull @CallbackExecutor Executor executor, + @NonNull Consumer<TextBoundsInfoResult> consumer) { + mTarget.requestTextBoundsInfo(rectF, executor, consumer); + } + + /** + * {@inheritDoc} + * @throws NullPointerException if the target is {@code null}. + */ + @Override public Handler getHandler() { return mTarget.getHandler(); } diff --git a/core/java/android/view/inputmethod/RemoteInputConnectionImpl.java b/core/java/android/view/inputmethod/RemoteInputConnectionImpl.java index f2b70997de63..1ad7091d3c6e 100644 --- a/core/java/android/view/inputmethod/RemoteInputConnectionImpl.java +++ b/core/java/android/view/inputmethod/RemoteInputConnectionImpl.java @@ -28,6 +28,7 @@ import static java.lang.annotation.RetentionPolicy.SOURCE; import android.annotation.AnyThread; import android.annotation.NonNull; import android.annotation.Nullable; +import android.graphics.RectF; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -1147,6 +1148,36 @@ final class RemoteInputConnectionImpl extends IRemoteInputConnection.Stub { @Dispatching(cancellable = true) @Override + public void requestTextBoundsInfo( + InputConnectionCommandHeader header, RectF rectF, + @NonNull ResultReceiver resultReceiver) { + dispatchWithTracing("requestTextBoundsInfo", () -> { + if (header.mSessionId != mCurrentSessionId.get()) { + resultReceiver.send(TextBoundsInfoResult.CODE_CANCELLED, null); + return; // cancelled + } + InputConnection ic = getInputConnection(); + if (ic == null || !isActive()) { + Log.w(TAG, "requestTextBoundsInfo on inactive InputConnection"); + resultReceiver.send(TextBoundsInfoResult.CODE_CANCELLED, null); + return; + } + + ic.requestTextBoundsInfo( + rectF, + Runnable::run, + (textBoundsInfoResult) -> { + final int resultCode = textBoundsInfoResult.getResultCode(); + final TextBoundsInfo textBoundsInfo = + textBoundsInfoResult.getTextBoundsInfo(); + resultReceiver.send(resultCode, + textBoundsInfo == null ? null : textBoundsInfo.toBundle()); + }); + }); + } + + @Dispatching(cancellable = true) + @Override public void commitContent(InputConnectionCommandHeader header, InputContentInfo inputContentInfo, int flags, Bundle opts, AndroidFuture future /* T=Boolean */) { diff --git a/core/java/android/view/inputmethod/TextBoundsInfo.java b/core/java/android/view/inputmethod/TextBoundsInfo.java new file mode 100644 index 000000000000..4e87405f0137 --- /dev/null +++ b/core/java/android/view/inputmethod/TextBoundsInfo.java @@ -0,0 +1,844 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.view.inputmethod; + +import android.annotation.IntDef; +import android.annotation.IntRange; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.SegmentFinder; + +import com.android.internal.util.ArrayUtils; +import com.android.internal.util.GrowingArrayUtils; +import com.android.internal.util.Preconditions; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/** + * The text bounds information of a slice of text in the editor. + * + * <p> This class provides IME the layout information of the text within the range from + * {@link #getStart()} to {@link #getEnd()}. It's intended to be used by IME as a supplementary API + * to support handwriting gestures. + * </p> + */ +public final class TextBoundsInfo implements Parcelable { + /** + * The flag indicating that the character is a whitespace. + * + * @see Builder#setCharacterFlags(int[]) + * @see #getCharacterFlags(int) + */ + public static final int FLAG_CHARACTER_WHITESPACE = 1; + + /** + * The flag indicating that the character is a linefeed character. + * + * @see Builder#setCharacterFlags(int[]) + * @see #getCharacterFlags(int) + */ + public static final int FLAG_CHARACTER_LINEFEED = 1 << 1; + + /** + * The flag indicating that the character is a punctuation. + * + * @see Builder#setCharacterFlags(int[]) + * @see #getCharacterFlags(int) + */ + public static final int FLAG_CHARACTER_PUNCTUATION = 1 << 2; + + /** + * The flag indicating that the line this character belongs to has RTL line direction. It's + * required that all characters in the same line must have the same direction. + * + * @see Builder#setCharacterFlags(int[]) + * @see #getCharacterFlags(int) + */ + public static final int FLAG_LINE_IS_RTL = 1 << 3; + + + /** @hide */ + @IntDef(prefix = "FLAG_", flag = true, value = { + FLAG_CHARACTER_WHITESPACE, + FLAG_CHARACTER_LINEFEED, + FLAG_CHARACTER_PUNCTUATION, + FLAG_LINE_IS_RTL + }) + @Retention(RetentionPolicy.SOURCE) + public @interface CharacterFlags {} + + /** All the valid flags. */ + private static final int KNOWN_CHARACTER_FLAGS = FLAG_CHARACTER_WHITESPACE + | FLAG_CHARACTER_LINEFEED | FLAG_CHARACTER_PUNCTUATION | FLAG_LINE_IS_RTL; + + /** + * The amount of shift to get the character's BiDi level from the internal character flags. + */ + private static final int BIDI_LEVEL_SHIFT = 19; + + /** + * The mask used to get the character's BiDi level from the internal character flags. + */ + private static final int BIDI_LEVEL_MASK = 0x7F << BIDI_LEVEL_SHIFT; + + /** + * The flag indicating that the character at the index is the start of a line segment. + * This flag is only used internally to serialize the {@link SegmentFinder}. + * + * @see #writeToParcel(Parcel, int) + */ + private static final int FLAG_LINE_SEGMENT_START = 1 << 31; + + /** + * The flag indicating that the character at the index is the end of a line segment. + * This flag is only used internally to serialize the {@link SegmentFinder}. + * + * @see #writeToParcel(Parcel, int) + */ + private static final int FLAG_LINE_SEGMENT_END = 1 << 30; + + /** + * The flag indicating that the character at the index is the start of a word segment. + * This flag is only used internally to serialize the {@link SegmentFinder}. + * + * @see #writeToParcel(Parcel, int) + */ + private static final int FLAG_WORD_SEGMENT_START = 1 << 29; + + /** + * The flag indicating that the character at the index is the end of a word segment. + * This flag is only used internally to serialize the {@link SegmentFinder}. + * + * @see #writeToParcel(Parcel, int) + */ + private static final int FLAG_WORD_SEGMENT_END = 1 << 28; + + /** + * The flag indicating that the character at the index is the start of a grapheme segment. + * It's only used internally to serialize the {@link SegmentFinder}. + * + * @see #writeToParcel(Parcel, int) + */ + private static final int FLAG_GRAPHEME_SEGMENT_START = 1 << 27; + + /** + * The flag indicating that the character at the index is the end of a grapheme segment. + * It's only used internally to serialize the {@link SegmentFinder}. + * + * @see #writeToParcel(Parcel, int) + */ + private static final int FLAG_GRAPHEME_SEGMENT_END = 1 << 26; + + private final int mStart; + private final int mEnd; + private final float[] mMatrixValues; + private final float[] mCharacterBounds; + /** + * The array that encodes character and BiDi levels. They are stored together to save memory + * space, and it's easier during serialization. + */ + private final int[] mInternalCharacterFlags; + private final SegmentFinder mLineSegmentFinder; + private final SegmentFinder mWordSegmentFinder; + private final SegmentFinder mGraphemeSegmentFinder; + + /** + * Returns a new instance of {@link android.graphics.Matrix} that indicates the transformation + * matrix that is to be applied other positional data in this class. + * + * @return a new instance (copy) of the transformation matrix. + */ + @NonNull + public Matrix getMatrix() { + final Matrix matrix = new Matrix(); + matrix.setValues(mMatrixValues); + return matrix; + } + + /** + * Returns the index of the first character whose bounds information is available in this + * {@link TextBoundsInfo}, inclusive. + * + * @see Builder#setStartAndEnd(int, int) + */ + public int getStart() { + return mStart; + } + + /** + * Returns the index of the last character whose bounds information is available in this + * {@link TextBoundsInfo}, exclusive. + * + * @see Builder#setStartAndEnd(int, int) + */ + public int getEnd() { + return mEnd; + } + + /** + * Return the bounds of the character at the given {@code index}, in the coordinates of the + * editor. + * + * @param index the index of the queried character. + * @return the bounding box of the queried character. + * + * @throws IndexOutOfBoundsException if the given {@code index} is out of the range from + * the {@code start} to the {@code end}. + */ + @NonNull + public RectF getCharacterBounds(int index) { + if (index < mStart || index >= mEnd) { + throw new IndexOutOfBoundsException("Index is out of the bounds of " + + "[" + mStart + ", " + mEnd + ")."); + } + final int offset = 4 * (index - mStart); + return new RectF(mCharacterBounds[offset], mCharacterBounds[offset + 1], + mCharacterBounds[offset + 2], mCharacterBounds[offset + 3]); + } + + /** + * Return the flags associated with the character at the given {@code index}. + * The flags contain the following information: + * <ul> + * <li>The {@link #FLAG_CHARACTER_WHITESPACE} flag, indicating the character is a + * whitespace. </li> + * <li>The {@link #FLAG_CHARACTER_LINEFEED} flag, indicating the character is a + * linefeed. </li> + * <li>The {@link #FLAG_CHARACTER_PUNCTUATION} flag, indicating the character is a + * punctuation. </li> + * <li>The {@link #FLAG_LINE_IS_RTL} flag, indicating the line this character belongs to + * has RTL line direction. All characters in the same line must have the same line + * direction. Check {@link #getLineSegmentFinder()} for more information of + * line boundaries. </li> + * </ul> + * + * @param index the index of the queried character. + * @return the flags associated with the queried character. + * + * @throws IndexOutOfBoundsException if the given {@code index} is out of the range from + * the {@code start} to the {@code end}. + * + * @see #FLAG_CHARACTER_WHITESPACE + * @see #FLAG_CHARACTER_LINEFEED + * @see #FLAG_CHARACTER_PUNCTUATION + * @see #FLAG_LINE_IS_RTL + */ + @CharacterFlags + public int getCharacterFlags(int index) { + if (index < mStart || index >= mEnd) { + throw new IndexOutOfBoundsException("Index is out of the bounds of " + + "[" + mStart + ", " + mEnd + ")."); + } + final int offset = index - mStart; + return mInternalCharacterFlags[offset] & KNOWN_CHARACTER_FLAGS; + } + + /** + * The BiDi level of the character at the given {@code index}. <br/> + * BiDi level is defined by + * <a href="https://unicode.org/reports/tr9/#Basic_Display_Algorithm" >the unicode + * bidirectional algorithm </a>. One can determine whether a character's direction is + * right-to-left (RTL) or left-to-right (LTR) by checking the last bit of the BiDi level. + * If it's 1, the character is RTL, otherwise the character is LTR. The BiDi level of a + * character must be in the range of [0, 125]. + * + * @param index the index of the queried character. + * @return the BiDi level of the character, which is an integer in the range of [0, 125]. + * @throws IndexOutOfBoundsException if the given {@code index} is out of the range from + * the {@code start} to the {@code end}. + * + * @see Builder#setCharacterBidiLevel(int[]) + */ + @IntRange(from = 0, to = 125) + public int getCharacterBidiLevel(int index) { + if (index < mStart || index >= mEnd) { + throw new IndexOutOfBoundsException("Index is out of the bounds of " + + "[" + mStart + ", " + mEnd + ")."); + } + final int offset = index - mStart; + return (mInternalCharacterFlags[offset] & BIDI_LEVEL_MASK) >> BIDI_LEVEL_SHIFT; + } + + /** + * Returns the {@link SegmentFinder} that locates the word boundaries. + * + * @see Builder#setWordSegmentFinder(SegmentFinder) + */ + @NonNull + public SegmentFinder getWordSegmentFinder() { + return mWordSegmentFinder; + } + + /** + * Returns the {@link SegmentFinder} that locates the grapheme boundaries. + * + * @see Builder#setGraphemeSegmentFinder(SegmentFinder) + */ + @NonNull + public SegmentFinder getGraphemeSegmentFinder() { + return mGraphemeSegmentFinder; + } + + /** + * Returns the {@link SegmentFinder} that locates the line boundaries. + * + * @see Builder#setLineSegmentFinder(SegmentFinder) + */ + @NonNull + public SegmentFinder getLineSegmentFinder() { + return mLineSegmentFinder; + } + + /** + * Describe the kinds of special objects contained in this Parcelable + * instance's marshaled representation. For example, if the object will + * include a file descriptor in the output of {@link #writeToParcel(Parcel, int)}, + * the return value of this method must include the + * {@link #CONTENTS_FILE_DESCRIPTOR} bit. + * + * @return a bitmask indicating the set of special object types marshaled + * by this Parcelable object instance. + */ + @Override + public int describeContents() { + return 0; + } + + /** + * Flatten this object in to a Parcel. + * + * @param dest The Parcel in which the object should be written. + * @param flags Additional flags about how the object should be written. + * May be 0 or {@link #PARCELABLE_WRITE_RETURN_VALUE}. + */ + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(mStart); + dest.writeInt(mEnd); + dest.writeFloatArray(mMatrixValues); + dest.writeFloatArray(mCharacterBounds); + + // The end can also be a break position. We need an extra space to encode the breaks. + final int[] encodedFlags = Arrays.copyOf(mInternalCharacterFlags, mEnd - mStart + 1); + encodeSegmentFinder(encodedFlags, FLAG_GRAPHEME_SEGMENT_START, FLAG_GRAPHEME_SEGMENT_END, + mStart, mEnd, mGraphemeSegmentFinder); + encodeSegmentFinder(encodedFlags, FLAG_WORD_SEGMENT_START, FLAG_WORD_SEGMENT_END, mStart, + mEnd, mWordSegmentFinder); + encodeSegmentFinder(encodedFlags, FLAG_LINE_SEGMENT_START, FLAG_LINE_SEGMENT_END, mStart, + mEnd, mLineSegmentFinder); + dest.writeIntArray(encodedFlags); + } + + private TextBoundsInfo(Parcel source) { + mStart = source.readInt(); + mEnd = source.readInt(); + mMatrixValues = Objects.requireNonNull(source.createFloatArray()); + mCharacterBounds = Objects.requireNonNull(source.createFloatArray()); + final int[] encodedFlags = Objects.requireNonNull(source.createIntArray()); + + mGraphemeSegmentFinder = decodeSegmentFinder(encodedFlags, FLAG_GRAPHEME_SEGMENT_START, + FLAG_GRAPHEME_SEGMENT_END, mStart, mEnd); + mWordSegmentFinder = decodeSegmentFinder(encodedFlags, FLAG_WORD_SEGMENT_START, + FLAG_WORD_SEGMENT_END, mStart, mEnd); + mLineSegmentFinder = decodeSegmentFinder(encodedFlags, FLAG_LINE_SEGMENT_START, + FLAG_LINE_SEGMENT_END, mStart, mEnd); + + final int length = mEnd - mStart; + final int flagsMask = KNOWN_CHARACTER_FLAGS | BIDI_LEVEL_MASK; + mInternalCharacterFlags = new int[length]; + for (int i = 0; i < length; ++i) { + // Remove the flags used to encoded segment boundaries. + mInternalCharacterFlags[i] = encodedFlags[i] & flagsMask; + } + } + + private TextBoundsInfo(Builder builder) { + mStart = builder.mStart; + mEnd = builder.mEnd; + mMatrixValues = Arrays.copyOf(builder.mMatrixValues, 9); + final int length = mEnd - mStart; + mCharacterBounds = Arrays.copyOf(builder.mCharacterBounds, 4 * length); + // Store characterFlags and characterBidiLevels to save memory. + mInternalCharacterFlags = new int[length]; + for (int index = 0; index < length; ++index) { + mInternalCharacterFlags[index] = builder.mCharacterFlags[index] + | (builder.mCharacterBidiLevels[index] << BIDI_LEVEL_SHIFT); + } + mGraphemeSegmentFinder = builder.mGraphemeSegmentFinder; + mWordSegmentFinder = builder.mWordSegmentFinder; + mLineSegmentFinder = builder.mLineSegmentFinder; + } + + /** + * The CREATOR to make this class Parcelable. + */ + @NonNull + public static final Parcelable.Creator<TextBoundsInfo> CREATOR = new Creator<TextBoundsInfo>() { + @Override + public TextBoundsInfo createFromParcel(Parcel source) { + return new TextBoundsInfo(source); + } + + @Override + public TextBoundsInfo[] newArray(int size) { + return new TextBoundsInfo[size]; + } + }; + + private static final String TEXT_BOUNDS_INFO_KEY = "android.view.inputmethod.TextBoundsInfo"; + + /** + * Store the {@link TextBoundsInfo} into a {@link Bundle}. This method is used by + * {@link RemoteInputConnectionImpl} to transfer the {@link TextBoundsInfo} from the editor + * to IME. + * + * @see TextBoundsInfoResult + * @see InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer) + * @hide + */ + @NonNull + public Bundle toBundle() { + final Bundle bundle = new Bundle(); + bundle.putParcelable(TEXT_BOUNDS_INFO_KEY, this); + return bundle; + + } + + /** @hide */ + @Nullable + public static TextBoundsInfo createFromBundle(@Nullable Bundle bundle) { + if (bundle == null) return null; + return bundle.getParcelable(TEXT_BOUNDS_INFO_KEY, TextBoundsInfo.class); + } + + /** + * The builder class to create a {@link TextBoundsInfo} object. + */ + public static final class Builder { + private final float[] mMatrixValues = new float[9]; + private boolean mMatrixInitialized; + private int mStart; + private int mEnd; + private float[] mCharacterBounds; + private int[] mCharacterFlags; + private int[] mCharacterBidiLevels; + private SegmentFinder mLineSegmentFinder; + private SegmentFinder mWordSegmentFinder; + private SegmentFinder mGraphemeSegmentFinder; + + /** Clear all the parameters set on this {@link Builder} to reuse it. */ + @NonNull + public Builder clear() { + mMatrixInitialized = false; + mStart = -1; + mEnd = -1; + mCharacterBounds = null; + mCharacterFlags = null; + mLineSegmentFinder = null; + mWordSegmentFinder = null; + mGraphemeSegmentFinder = null; + return this; + } + + /** + * Sets the matrix that transforms local coordinates into screen coordinates. + * + * @param matrix transformation matrix from local coordinates into screen coordinates. + * @throws NullPointerException if the given {@code matrix} is {@code null}. + */ + @NonNull + public Builder setMatrix(@NonNull Matrix matrix) { + Objects.requireNonNull(matrix).getValues(mMatrixValues); + mMatrixInitialized = true; + return this; + } + + /** + * Set the start and end index of the {@link TextBoundsInfo}. It's the range of the + * characters whose information is available in the {@link TextBoundsInfo}. + * + * @param start the start index of the {@link TextBoundsInfo}, inclusive. + * @param end the end index of the {@link TextBoundsInfo}, exclusive. + * @throws IllegalArgumentException if the given {@code start} or {@code end} is negative, + * or {@code end} is smaller than the {@code start}. + */ + @NonNull + @SuppressWarnings("MissingGetterMatchingBuilder") + public Builder setStartAndEnd(@IntRange(from = 0) int start, @IntRange(from = 0) int end) { + Preconditions.checkArgument(start >= 0); + Preconditions.checkArgumentInRange(start, 0, end, "start"); + mStart = start; + mEnd = end; + return this; + } + + /** + * Set the characters bounds, in the coordinates of the editor. <br/> + * + * The given array should be divided into groups of four where each element represents + * left, top, right and bottom of the character bounds respectively. + * The bounds of the i-th character in the editor should be stored at index + * 4 * (i - start). The length of the given array must equal to 4 * (end - start). <br/> + * + * Sometimes multiple characters in a single grapheme are rendered as one symbol on the + * screen. So those characters only have one shared bounds. In this case, we recommend the + * editor to assign all the width to the bounds of the first character in the grapheme, + * and make the rest characters' bounds zero-width. <br/> + * + * For example, the string "'0xD83D' '0xDE00'" is rendered as one grapheme - a grinning face + * emoji. If the bounds of the grapheme is: Rect(5, 10, 15, 20), the character bounds of the + * string should be: [ Rect(5, 10, 15, 20), Rect(15, 10, 15, 20) ]. + * + * @param characterBounds the array of the flattened character bounds. + * @throws NullPointerException if the given {@code characterBounds} is {@code null}. + */ + @NonNull + public Builder setCharacterBounds(@NonNull float[] characterBounds) { + mCharacterBounds = Objects.requireNonNull(characterBounds); + return this; + } + + /** + * Set the flags of the characters. The flags of the i-th character in the editor is stored + * at index (i - start). The length of the given array must equal to (end - start). + * The flags contain the following information: + * <ul> + * <li>The {@link #FLAG_CHARACTER_WHITESPACE} flag, indicating the character is a + * whitespace. </li> + * <li>The {@link #FLAG_CHARACTER_LINEFEED} flag, indicating the character is a + * linefeed. </li> + * <li>The {@link #FLAG_CHARACTER_PUNCTUATION} flag, indicating the character is a + * punctuation. </li> + * <li>The {@link #FLAG_LINE_IS_RTL} flag, indicating the line this character belongs to + * is RTL. All all character in the same line must have the same line direction. Check + * {@link #getLineSegmentFinder()} for more information of line boundaries. </li> + * </ul> + * + * @param characterFlags the array of the character's flags. + * @throws NullPointerException if the given {@code characterFlags} is {@code null}. + * @throws IllegalArgumentException if the given {@code characterFlags} contains invalid + * flags. + * + * @see #getCharacterFlags(int) + */ + @NonNull + public Builder setCharacterFlags(@NonNull int[] characterFlags) { + Objects.requireNonNull(characterFlags); + for (int characterFlag : characterFlags) { + if ((characterFlag & (~KNOWN_CHARACTER_FLAGS)) != 0) { + throw new IllegalArgumentException("characterFlags contains invalid flags."); + } + } + mCharacterFlags = characterFlags; + return this; + } + + /** + * Set the BiDi levels for the character. The bidiLevel of the i-th character in the editor + * is stored at index (i - start). The length of the given array must equal to + * (end - start). <br/> + * + * BiDi level is defined by + * <a href="https://unicode.org/reports/tr9/#Basic_Display_Algorithm" >the unicode + * bidirectional algorithm </a>. One can determine whether a character's direction is + * right-to-left (RTL) or left-to-right (LTR) by checking the last bit of the BiDi level. + * If it's 1, the character is RTL, otherwise the character is LTR. The BiDi level of a + * character must be in the range of [0, 125]. + * @param characterBidiLevels the array of the character's BiDi level. + * + * @throws NullPointerException if the given {@code characterBidiLevels} is {@code null}. + * @throws IllegalArgumentException if the given {@code characterBidiLevels} contains an + * element that's out of the range [0, 125]. + * + * @see #getCharacterBidiLevel(int) + */ + @NonNull + public Builder setCharacterBidiLevel(@NonNull int[] characterBidiLevels) { + Objects.requireNonNull(characterBidiLevels); + for (int index = 0; index < characterBidiLevels.length; ++index) { + Preconditions.checkArgumentInRange(characterBidiLevels[index], 0, 125, + "bidiLevels[" + index + "]"); + } + mCharacterBidiLevels = characterBidiLevels; + return this; + } + + /** + * Set the {@link SegmentFinder} that locates the grapheme cluster boundaries. Grapheme is + * defined in <a href="https://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries"> + * the unicode annex #29: unicode text segmentation<a/>. It's a user-perspective character. + * And it's usually the minimal unit for selection, backspace, deletion etc. <br/> + * + * Please note that only the grapheme segments within the range from start to end will + * be available to the IME. The remaining information will be discarded during serialization + * for better performance. + * + * @param graphemeSegmentFinder the {@link SegmentFinder} that locates the grapheme cluster + * boundaries. + * @throws NullPointerException if the given {@code graphemeSegmentFinder} is {@code null}. + * + * @see #getGraphemeSegmentFinder() + * @see SegmentFinder + * @see SegmentFinder.DefaultSegmentFinder + */ + @NonNull + public Builder setGraphemeSegmentFinder(@NonNull SegmentFinder graphemeSegmentFinder) { + mGraphemeSegmentFinder = Objects.requireNonNull(graphemeSegmentFinder); + return this; + } + + /** + * Set the {@link SegmentFinder} that locates the word boundaries. <br/> + * + * Please note that only the word segments within the range from start to end will + * be available to the IME. The remaining information will be discarded during serialization + * for better performance. + * @param wordSegmentFinder set the {@link SegmentFinder} that locates the word boundaries. + * @throws NullPointerException if the given {@code wordSegmentFinder} is {@code null}. + * + * @see #getWordSegmentFinder() + * @see SegmentFinder + * @see SegmentFinder.DefaultSegmentFinder + */ + @NonNull + public Builder setWordSegmentFinder(@NonNull SegmentFinder wordSegmentFinder) { + mWordSegmentFinder = Objects.requireNonNull(wordSegmentFinder); + return this; + } + + /** + * Set the {@link SegmentFinder} that locates the line boundaries. Aside from the hard + * breaks in the text, it should also locate the soft line breaks added by the editor. + * It is expected that the characters within the same line is rendered on the same baseline. + * (Except for some text formatted as subscript and superscript.) <br/> + * + * Please note that only the line segments within the range from start to end will + * be available to the IME. The remaining information will be discarded during serialization + * for better performance. + * @param lineSegmentFinder set the {@link SegmentFinder} that locates the line boundaries. + * @throws NullPointerException if the given {@code lineSegmentFinder} is {@code null}. + * + * @see #getLineSegmentFinder() + * @see SegmentFinder + * @see SegmentFinder.DefaultSegmentFinder + */ + @NonNull + public Builder setLineSegmentFinder(@NonNull SegmentFinder lineSegmentFinder) { + mLineSegmentFinder = Objects.requireNonNull(lineSegmentFinder); + return this; + } + + /** + * Create the {@link TextBoundsInfo} using the parameters in this {@link Builder}. + * + * @throws IllegalStateException in the following conditions: + * <ul> + * <li>if the {@code start} or {@code end} is not set.</li> + * <li>if the {@code matrix} is not set.</li> + * <li>if {@code characterBounds} is not set or its length doesn't equal to + * 4 * ({@code end} - {@code start}).</li> + * <li>if the {@code characterFlags} is not set or its length doesn't equal to + * ({@code end} - {@code start}).</li> + * <li>if {@code graphemeSegmentFinder}, {@code wordSegmentFinder} or + * {@code lineSegmentFinder} is not set.</li> + * <li>if characters in the same line has inconsistent {@link #FLAG_LINE_IS_RTL} + * flag.</li> + * </ul> + */ + @NonNull + public TextBoundsInfo build() { + if (mStart < 0 || mEnd < 0) { + throw new IllegalStateException("Start and end must be set."); + } + + if (!mMatrixInitialized) { + throw new IllegalStateException("Matrix must be set."); + } + + if (mCharacterBounds == null) { + throw new IllegalStateException("CharacterBounds must be set."); + } + + if (mCharacterFlags == null) { + throw new IllegalStateException("CharacterFlags must be set."); + } + + if (mCharacterBidiLevels == null) { + throw new IllegalStateException("CharacterBidiLevel must be set."); + } + + if (mCharacterBounds.length != 4 * (mEnd - mStart)) { + throw new IllegalStateException("The length of characterBounds doesn't match the " + + "length of the given start and end." + + " Expected length: " + (4 * (mEnd - mStart)) + + " characterBounds length: " + mCharacterBounds.length); + } + if (mCharacterFlags.length != mEnd - mStart) { + throw new IllegalStateException("The length of characterFlags doesn't match the " + + "length of the given start and end." + + " Expected length: " + (mEnd - mStart) + + " characterFlags length: " + mCharacterFlags.length); + } + if (mCharacterBidiLevels.length != mEnd - mStart) { + throw new IllegalStateException("The length of characterBidiLevels doesn't match" + + " the length of the given start and end." + + " Expected length: " + (mEnd - mStart) + + " characterFlags length: " + mCharacterBidiLevels.length); + } + if (mGraphemeSegmentFinder == null) { + throw new IllegalStateException("GraphemeSegmentFinder must be set."); + } + if (mWordSegmentFinder == null) { + throw new IllegalStateException("WordSegmentFinder must be set."); + } + if (mLineSegmentFinder == null) { + throw new IllegalStateException("LineSegmentFinder must be set."); + } + + if (!isLineDirectionFlagConsistent(mCharacterFlags, mLineSegmentFinder, mStart, mEnd)) { + throw new IllegalStateException("characters in the same line must have the same " + + "FLAG_LINE_IS_RTL flag value."); + } + return new TextBoundsInfo(this); + } + } + + /** + * Encode the segment start and end positions in {@link SegmentFinder} to a flags array. + * + * For example: + * Text: "A BC DE" + * Input: + * start: 2, end: 7 // substring "BC DE" + * SegmentFinder: segment ranges = [(2, 4), (5, 7)] // a word break iterator + * flags: [0x0000, 0x0000, 0x0080, 0x0000, 0x0000, 0x0000] // 0x0080 is whitespace + * segmentStartFlag: 0x0100 + * segmentEndFlag: 0x0200 + * Output: + * flags: [0x0100, 0x0000, 0x0280, 0x0100, 0x0000, 0x0200] + * The index 2 and 5 encode segment starts, the index 4 and 7 encode a segment end. + * + * @param flags the flags array to receive the results. + * @param segmentStartFlag the flag used to encode the segment start. + * @param segmentEndFlag the flag used to encode the segment end. + * @param start the start index of the encoded range, inclusive. + * @param end the end index of the encoded range, inclusive. + * @param segmentFinder the SegmentFinder to be encoded. + * + * @see #decodeSegmentFinder(int[], int, int, int, int) + */ + private static void encodeSegmentFinder(@NonNull int[] flags, int segmentStartFlag, + int segmentEndFlag, int start, int end, @NonNull SegmentFinder segmentFinder) { + if (end - start + 1 != flags.length) { + throw new IllegalStateException("The given flags array must have the same length as" + + " the given range. flags length: " + flags.length + + " range: [" + start + ", " + end + "]"); + } + + int segmentEnd = segmentFinder.nextEndBoundary(start); + if (segmentEnd == SegmentFinder.DONE) return; + int segmentStart = segmentFinder.previousStartBoundary(segmentEnd); + + while (segmentEnd != SegmentFinder.DONE && segmentEnd <= end) { + if (segmentStart >= start) { + flags[segmentStart - start] |= segmentStartFlag; + flags[segmentEnd - start] |= segmentEndFlag; + } + segmentStart = segmentFinder.nextStartBoundary(segmentStart); + segmentEnd = segmentFinder.nextEndBoundary(segmentEnd); + } + } + + /** + * Decode a {@link SegmentFinder} from a flags array. + * + * For example: + * Text: "A BC DE" + * Input: + * start: 2, end: 7 // substring "BC DE" + * flags: [0x0100, 0x0000, 0x0280, 0x0100, 0x0000, 0x0200] + * segmentStartFlag: 0x0100 + * segmentEndFlag: 0x0200 + * Output: + * SegmentFinder: segment ranges = [(2, 4), (5, 7)] + * + * @param flags the flags array to decode the SegmentFinder. + * @param segmentStartFlag the flag to decode a segment start. + * @param segmentEndFlag the flag to decode a segment end. + * @param start the start index of the interested range, inclusive. + * @param end the end index of the interested range, inclusive. + * + * @see #encodeSegmentFinder(int[], int, int, int, int, SegmentFinder) + */ + private static SegmentFinder decodeSegmentFinder(int[] flags, int segmentStartFlag, + int segmentEndFlag, int start, int end) { + if (end - start + 1 != flags.length) { + throw new IllegalStateException("The given flags array must have the same length as" + + " the given range. flags length: " + flags.length + + " range: [" + start + ", " + end + "]"); + } + int[] breaks = ArrayUtils.newUnpaddedIntArray(10); + int count = 0; + for (int offset = 0; offset < flags.length; ++offset) { + if ((flags[offset] & segmentStartFlag) == segmentStartFlag) { + breaks = GrowingArrayUtils.append(breaks, count++, start + offset); + } + if ((flags[offset] & segmentEndFlag) == segmentEndFlag) { + breaks = GrowingArrayUtils.append(breaks, count++, start + offset); + } + } + return new SegmentFinder.DefaultSegmentFinder(Arrays.copyOf(breaks, count)); + } + + /** + * Check whether the {@link #FLAG_LINE_IS_RTL} is the same for characters in the same line. + * @return true if all characters in the same line has the same {@link #FLAG_LINE_IS_RTL} flag. + */ + private static boolean isLineDirectionFlagConsistent(int[] characterFlags, + SegmentFinder lineSegmentFinder, int start, int end) { + int segmentEnd = lineSegmentFinder.nextEndBoundary(start); + if (segmentEnd == SegmentFinder.DONE) return true; + int segmentStart = lineSegmentFinder.previousStartBoundary(segmentEnd); + + while (segmentStart != SegmentFinder.DONE && segmentStart < end) { + final int lineStart = Math.max(segmentStart, start); + final int lineEnd = Math.min(segmentEnd, end); + final boolean lineIsRtl = (characterFlags[lineStart - start] & FLAG_LINE_IS_RTL) != 0; + for (int index = lineStart + 1; index < lineEnd; ++index) { + final int flags = characterFlags[index - start]; + final boolean characterLineIsRtl = (flags & FLAG_LINE_IS_RTL) != 0; + if (characterLineIsRtl != lineIsRtl) { + return false; + } + } + + segmentStart = lineSegmentFinder.nextStartBoundary(segmentStart); + segmentEnd = lineSegmentFinder.nextEndBoundary(segmentEnd); + } + return true; + } +} diff --git a/core/java/android/view/inputmethod/TextBoundsInfoResult.java b/core/java/android/view/inputmethod/TextBoundsInfoResult.java new file mode 100644 index 000000000000..62df17a3aeae --- /dev/null +++ b/core/java/android/view/inputmethod/TextBoundsInfoResult.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.view.inputmethod; + +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.graphics.RectF; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/** + * The object that holds the result of the + * {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)} call. + * + * @see InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer) + */ +public final class TextBoundsInfoResult { + private final int mResultCode; + private final TextBoundsInfo mTextBoundsInfo; + + /** + * Result for {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)} when the + * editor doesn't implement the method. + */ + public static final int CODE_UNSUPPORTED = 0; + + /** + * Result for {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)} when the + * editor successfully returns a {@link TextBoundsInfo}. + */ + public static final int CODE_SUCCESS = 1; + + /** + * Result for {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)} when the + * request failed. This result code is returned when the editor can't provide a valid + * {@link TextBoundsInfo}. (e.g. The editor view is not laid out.) + */ + public static final int CODE_FAILED = 2; + + /** + * Result for {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)} when the + * request is cancelled. This happens when the {@link InputConnection} is or becomes + * invalidated while requesting the + * {@link TextBoundsInfo}, for example because a new {@code InputConnection} was started, or + * due to {@link InputMethodManager#invalidateInput}. + */ + public static final int CODE_CANCELLED = 3; + + /** @hide */ + @IntDef(prefix = { "CODE_" }, value = { + CODE_UNSUPPORTED, + CODE_SUCCESS, + CODE_FAILED, + CODE_CANCELLED, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ResultCode {} + + /** + * Create a {@link TextBoundsInfoResult} object with no {@link TextBoundsInfo}. + * The given {@code resultCode} can't be {@link #CODE_SUCCESS}. + * @param resultCode the result code of the + * {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)} call. + */ + public TextBoundsInfoResult(@ResultCode int resultCode) { + this(resultCode, null); + } + + /** + * Create a {@link TextBoundsInfoResult} object. + * + * @param resultCode the result code of the + * {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)} call. + * @param textBoundsInfo the returned {@link TextBoundsInfo} of the + * {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)} call. It can't be + * null if the {@code resultCode} is {@link #CODE_SUCCESS}. + * + * @throws IllegalStateException if the resultCode is + * {@link #CODE_SUCCESS} but the given {@code textBoundsInfo} + * is null. + */ + public TextBoundsInfoResult(@ResultCode int resultCode, + @NonNull TextBoundsInfo textBoundsInfo) { + if (resultCode == CODE_SUCCESS && textBoundsInfo == null) { + throw new IllegalStateException("TextBoundsInfo must be provided when the resultCode " + + "is CODE_SUCCESS."); + } + mResultCode = resultCode; + mTextBoundsInfo = textBoundsInfo; + } + + /** + * Return the result code of the + * {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)} call. + * Its value is one of the {@link #CODE_UNSUPPORTED}, {@link #CODE_SUCCESS}, + * {@link #CODE_FAILED} and {@link #CODE_CANCELLED}. + */ + @ResultCode + public int getResultCode() { + return mResultCode; + } + + /** + * Return the {@link TextBoundsInfo} provided by the editor. It is non-null if the + * {@code resultCode} is {@link #CODE_SUCCESS}. + * Otherwise, it can be null in the following conditions: + * <ul> + * <li>the editor doesn't support + * {@link InputConnection#requestTextBoundsInfo(RectF, Executor, Consumer)}.</li> + * <li>the editor doesn't have the text bounds information at the moment. (e.g. the editor + * view is not laid out yet.) </li> + * <li> the {@link InputConnection} is or become inactive during the request. </li> + * <ul/> + */ + @Nullable + public TextBoundsInfo getTextBoundsInfo() { + return mTextBoundsInfo; + } +} diff --git a/core/java/com/android/internal/inputmethod/IRemoteInputConnection.aidl b/core/java/com/android/internal/inputmethod/IRemoteInputConnection.aidl index ea5c9a33b762..eb1707fccaf4 100644 --- a/core/java/com/android/internal/inputmethod/IRemoteInputConnection.aidl +++ b/core/java/com/android/internal/inputmethod/IRemoteInputConnection.aidl @@ -16,6 +16,7 @@ package com.android.internal.inputmethod; +import android.graphics.RectF; import android.os.Bundle; import android.os.ResultReceiver; import android.view.KeyEvent; @@ -130,6 +131,9 @@ import com.android.internal.inputmethod.InputConnectionCommandHeader; int cursorUpdateMode, int cursorUpdateFilter, int imeDisplayId, in AndroidFuture future /* T=Boolean */); + void requestTextBoundsInfo(in InputConnectionCommandHeader header, in RectF rect, + in ResultReceiver resultReceiver /* T=TextBoundsInfoResult */); + void commitContent(in InputConnectionCommandHeader header, in InputContentInfo inputContentInfo, int flags, in Bundle opts, in AndroidFuture future /* T=Boolean */); |