Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 90b8d499 authored by Nikita Dubrovsky's avatar Nikita Dubrovsky
Browse files

Integrate ACTION_PROCESS_TEXT with RichContentReceiver

Bug: 152068298
Bug: 152808432
Test: Ran existing/new tests for ACTION_PROCESS_TEXT
  atest FrameworksCoreTests:TextViewTest
  atest FrameworksCoreTests:TextViewProcessTextTest
Change-Id: Idf88839f96087cdc403ea1b5413c238413e005d3
parent 47c67454
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -60576,6 +60576,7 @@ package android.widget {
    field public static final int SOURCE_DRAG_AND_DROP = 2; // 0x2
    field public static final int SOURCE_INPUT_METHOD = 1; // 0x1
    field public static final int SOURCE_MENU = 0; // 0x0
    field public static final int SOURCE_PROCESS_TEXT = 4; // 0x4
  }
  public class ScrollView extends android.widget.FrameLayout {
+7 −1
Original line number Diff line number Diff line
@@ -75,7 +75,7 @@ public interface RichContentReceiver<T extends View> {
     * @hide
     */
    @IntDef(prefix = {"SOURCE_"}, value = {SOURCE_MENU, SOURCE_INPUT_METHOD, SOURCE_DRAG_AND_DROP,
            SOURCE_AUTOFILL})
            SOURCE_AUTOFILL, SOURCE_PROCESS_TEXT})
    @Retention(RetentionPolicy.SOURCE)
    @interface Source {}

@@ -104,6 +104,12 @@ public interface RichContentReceiver<T extends View> {
     */
    int SOURCE_AUTOFILL = 3;

    /**
     * Specifies that the operation was triggered by a result from a
     * {@link android.content.Intent#ACTION_PROCESS_TEXT PROCESS_TEXT} action in the selection menu.
     */
    int SOURCE_PROCESS_TEXT = 4;

    /**
     * Flags to configure the insertion behavior.
     *
+3 −5
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_C
import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX;
import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY;
import static android.view.inputmethod.CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION;
import static android.widget.RichContentReceiver.SOURCE_PROCESS_TEXT;
import android.R;
import android.annotation.CallSuper;
@@ -2114,7 +2115,8 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener
                CharSequence result = data.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT);
                if (result != null) {
                    if (isTextEditable()) {
                        replaceSelectionWithText(result);
                        ClipData clip = ClipData.newPlainText("", result);
                        mRichContentReceiver.onReceive(this, clip, SOURCE_PROCESS_TEXT, 0);
                        if (mEditor != null) {
                            mEditor.refreshTextActionMode();
                        }
@@ -12788,10 +12790,6 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener
        return length > 0;
    }
    void replaceSelectionWithText(CharSequence text) {
        ((Editable) mText).replace(getSelectionStart(), getSelectionEnd(), text);
    }
    private void paste(boolean withFormatting) {
        ClipboardManager clipboard = getClipboardManagerForUser();
        ClipData clip = clipboard.getPrimaryClip();
+16 −11
Original line number Diff line number Diff line
@@ -49,7 +49,7 @@ final class TextViewRichContentReceiver implements RichContentReceiver<TextView>

    @Override
    public boolean onReceive(@NonNull TextView textView, @NonNull ClipData clip,
            @Source int source, int flags) {
            @Source int source, @Flags int flags) {
        if (source == SOURCE_AUTOFILL) {
            return onReceiveForAutofill(textView, clip, flags);
        }
@@ -78,12 +78,7 @@ final class TextViewRichContentReceiver implements RichContentReceiver<TextView>
            }
            if (itemText != null) {
                if (!didFirst) {
                    final int selStart = Selection.getSelectionStart(editable);
                    final int selEnd = Selection.getSelectionEnd(editable);
                    final int start = Math.max(0, Math.min(selStart, selEnd));
                    final int end = Math.max(0, Math.max(selStart, selEnd));
                    Selection.setSelection(editable, end);
                    editable.replace(start, end, itemText);
                    replaceSelection(editable, itemText);
                    didFirst = true;
                } else {
                    editable.insert(Selection.getSelectionEnd(editable), "\n");
@@ -94,8 +89,18 @@ final class TextViewRichContentReceiver implements RichContentReceiver<TextView>
        return didFirst;
    }

    private static void replaceSelection(@NonNull Editable editable,
            @NonNull CharSequence replacement) {
        final int selStart = Selection.getSelectionStart(editable);
        final int selEnd = Selection.getSelectionEnd(editable);
        final int start = Math.max(0, Math.min(selStart, selEnd));
        final int end = Math.max(0, Math.max(selStart, selEnd));
        Selection.setSelection(editable, end);
        editable.replace(start, end, replacement);
    }

    private static boolean onReceiveForAutofill(@NonNull TextView textView, @NonNull ClipData clip,
            int flags) {
            @Flags int flags) {
        final CharSequence text = coerceToText(clip, textView.getContext(), flags);
        if (text.length() == 0) {
            return false;
@@ -109,16 +114,16 @@ final class TextViewRichContentReceiver implements RichContentReceiver<TextView>
    }

    private static boolean onReceiveForDragAndDrop(@NonNull TextView textView,
            @NonNull ClipData clip, int flags) {
            @NonNull ClipData clip, @Flags int flags) {
        final CharSequence text = coerceToText(clip, textView.getContext(), flags);
        if (text.length() == 0) {
            return false;
        }
        textView.replaceSelectionWithText(text);
        replaceSelection((Editable) textView.getText(), text);
        return true;
    }

    private static CharSequence coerceToText(ClipData clip, Context context, int flags) {
    private static CharSequence coerceToText(ClipData clip, Context context, @Flags int flags) {
        SpannableStringBuilder ssb = new SpannableStringBuilder();
        for (int i = 0; i < clip.getItemCount(); i++) {
            CharSequence itemText;
+229 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 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.widget;

import static android.widget.RichContentReceiver.SOURCE_PROCESS_TEXT;

import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.verifyZeroInteractions;

import android.app.Activity;
import android.app.Instrumentation;
import android.content.ClipData;
import android.content.ClipDescription;
import android.content.Intent;
import android.text.Selection;
import android.text.Spannable;
import android.widget.TextView.BufferType;

import androidx.test.InstrumentationRegistry;
import androidx.test.filters.MediumTest;
import androidx.test.rule.ActivityTestRule;
import androidx.test.runner.AndroidJUnit4;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentMatcher;
import org.mockito.Mockito;

import java.util.Set;

/**
 * Tests for {@link Intent#ACTION_PROCESS_TEXT} functionality in {@link TextView}.
 */
@RunWith(AndroidJUnit4.class)
@MediumTest
public class TextViewProcessTextTest {
    @Rule
    public ActivityTestRule<TextViewActivity> mActivityRule = new ActivityTestRule<>(
            TextViewActivity.class);
    private Instrumentation mInstrumentation;
    private Activity mActivity;
    private TextView mTextView;

    @Before
    public void before() {
        mInstrumentation = InstrumentationRegistry.getInstrumentation();
        mActivity = mActivityRule.getActivity();
    }

    @Test
    public void testProcessTextActivityResultNonEditable() throws Throwable {
        mActivityRule.runOnUiThread(() -> mTextView = new TextView(mActivity));
        mInstrumentation.waitForIdleSync();
        CharSequence originalText = "This is some text.";
        mTextView.setText(originalText, BufferType.SPANNABLE);
        assertEquals(originalText, mTextView.getText().toString());
        mTextView.setTextIsSelectable(true);
        Selection.setSelection((Spannable) mTextView.getText(), 0, mTextView.getText().length());

        MockReceiverWrapper mockReceiverWrapper = new MockReceiverWrapper();
        mTextView.setRichContentReceiver(mockReceiverWrapper);

        // We need to run this in the UI thread, as it will create a Toast.
        mActivityRule.runOnUiThread(() -> {
            triggerOnActivityResult(Activity.RESULT_OK, "Text is replaced.");
        });
        mInstrumentation.waitForIdleSync();

        // This is a TextView, which can't be modified. Hence no change should have been made.
        assertEquals(originalText, mTextView.getText().toString());
        verifyZeroInteractions(mockReceiverWrapper.mMock);
    }

    @Test
    public void testProcessTextActivityResultEditable_defaultRichContentReceiver()
            throws Throwable {
        mActivityRule.runOnUiThread(() -> mTextView = new EditText(mActivity));
        mInstrumentation.waitForIdleSync();
        CharSequence originalText = "This is some text.";
        mTextView.setText(originalText, BufferType.SPANNABLE);
        assertEquals(originalText, mTextView.getText().toString());
        mTextView.setTextIsSelectable(true);
        Selection.setSelection(((EditText) mTextView).getText(), 0, mTextView.getText().length());

        CharSequence newText = "Text is replaced.";
        triggerOnActivityResult(Activity.RESULT_OK, newText);

        assertEquals(newText, mTextView.getText().toString());
    }

    @Test
    public void testProcessTextActivityResultEditable_customRichContentReceiver() throws Throwable {
        mActivityRule.runOnUiThread(() -> mTextView = new EditText(mActivity));
        mInstrumentation.waitForIdleSync();
        CharSequence originalText = "This is some text.";
        mTextView.setText(originalText, BufferType.SPANNABLE);
        assertEquals(originalText, mTextView.getText().toString());
        mTextView.setTextIsSelectable(true);
        Selection.setSelection(((EditText) mTextView).getText(), 0, mTextView.getText().length());

        MockReceiverWrapper mockReceiverWrapper = new MockReceiverWrapper();
        mTextView.setRichContentReceiver(mockReceiverWrapper);

        CharSequence newText = "Text is replaced.";
        triggerOnActivityResult(Activity.RESULT_OK, newText);

        ClipData expectedClip = ClipData.newPlainText("", newText);
        verify(mockReceiverWrapper.mMock, times(1)).onReceive(
                eq(mTextView), clipEq(expectedClip), eq(SOURCE_PROCESS_TEXT), eq(0));
        verifyNoMoreInteractions(mockReceiverWrapper.mMock);
    }

    @Test
    public void testProcessTextActivityResultCancel() throws Throwable {
        mActivityRule.runOnUiThread(() -> mTextView = new EditText(mActivity));
        mInstrumentation.waitForIdleSync();
        CharSequence originalText = "This is some text.";
        mTextView.setText(originalText, BufferType.SPANNABLE);
        assertEquals(originalText, mTextView.getText().toString());
        mTextView.setTextIsSelectable(true);
        Selection.setSelection(((EditText) mTextView).getText(), 0, mTextView.getText().length());

        MockReceiverWrapper mockReceiverWrapper = new MockReceiverWrapper();
        mTextView.setRichContentReceiver(mockReceiverWrapper);

        CharSequence newText = "Text is replaced.";
        triggerOnActivityResult(Activity.RESULT_CANCELED, newText);

        assertEquals(originalText, mTextView.getText().toString());
        verifyZeroInteractions(mockReceiverWrapper.mMock);
    }

    @Test
    public void testProcessTextActivityNoData() throws Throwable {
        mActivityRule.runOnUiThread(() -> mTextView = new EditText(mActivity));
        mInstrumentation.waitForIdleSync();
        CharSequence originalText = "This is some text.";
        mTextView.setText(originalText, BufferType.SPANNABLE);
        assertEquals(originalText, mTextView.getText().toString());
        mTextView.setTextIsSelectable(true);
        Selection.setSelection(((EditText) mTextView).getText(), 0, mTextView.getText().length());

        MockReceiverWrapper mockReceiverWrapper = new MockReceiverWrapper();
        mTextView.setRichContentReceiver(mockReceiverWrapper);

        mTextView.onActivityResult(TextView.PROCESS_TEXT_REQUEST_CODE, Activity.RESULT_OK, null);

        assertEquals(originalText, mTextView.getText().toString());
        verifyZeroInteractions(mockReceiverWrapper.mMock);
    }

    private void triggerOnActivityResult(int resultCode, CharSequence replacementText) {
        Intent data = new Intent();
        data.putExtra(Intent.EXTRA_PROCESS_TEXT, replacementText);
        mTextView.onActivityResult(TextView.PROCESS_TEXT_REQUEST_CODE, resultCode, data);
    }

    // This wrapper is used so that we only mock and verify the public callback methods. In addition
    // to the public methods, the RichContentReceiver interface has some hidden default methods;
    // we don't want to mock or assert calls to these helper functions (they are an implementation
    // detail).
    private static class MockReceiverWrapper implements RichContentReceiver<TextView> {
        private final RichContentReceiver<TextView> mMock;

        @SuppressWarnings("unchecked")
        MockReceiverWrapper() {
            this.mMock = Mockito.mock(RichContentReceiver.class);
        }

        public RichContentReceiver<TextView> getMock() {
            return mMock;
        }

        @Override
        public boolean onReceive(TextView view, ClipData clip, @Source int source,
                @Flags int flags) {
            return mMock.onReceive(view, clip, source, flags);
        }

        @Override
        public Set<String> getSupportedMimeTypes() {
            return mMock.getSupportedMimeTypes();
        }
    }

    private static ClipData clipEq(ClipData expected) {
        return argThat(new ClipDataArgumentMatcher(expected));
    }

    private static class ClipDataArgumentMatcher implements ArgumentMatcher<ClipData> {
        private final ClipData mExpected;

        private ClipDataArgumentMatcher(ClipData expected) {
            this.mExpected = expected;
        }

        @Override
        public boolean matches(ClipData actual) {
            ClipDescription actualDesc = actual.getDescription();
            ClipDescription expectedDesc = mExpected.getDescription();
            return expectedDesc.getLabel().equals(actualDesc.getLabel())
                    && actualDesc.getMimeTypeCount() == 1
                    && expectedDesc.getMimeType(0).equals(actualDesc.getMimeType(0))
                    && actual.getItemCount() == 1
                    && mExpected.getItemAt(0).getText().equals(actual.getItemAt(0).getText());
        }
    }
}
Loading