blob: b96293f6ff18b328e28544b654f3070f914d62ce [file] [log] [blame]
/*
* 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.view.textclassifier.cts;
import static android.content.pm.PackageManager.FEATURE_TOUCHSCREEN;
import static android.provider.Settings.Global.ANIMATOR_DURATION_SCALE;
import static android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static com.google.common.truth.Truth.assertThat;
import static org.hamcrest.CoreMatchers.allOf;
import android.app.PendingIntent;
import android.app.RemoteAction;
import android.content.ContentResolver;
import android.content.Intent;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.provider.Settings;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.util.Log;
import android.view.textclassifier.TextClassification;
import android.view.textclassifier.TextClassifier;
import android.view.textclassifier.TextLinks;
import android.view.textclassifier.TextSelection;
import android.widget.TextView;
import androidx.core.os.BuildCompat;
import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.By;
import androidx.test.uiautomator.UiDevice;
import com.android.compatibility.common.util.ShellUtils;
import com.android.compatibility.common.util.SystemUtil;
import org.junit.AfterClass;
import org.junit.Assume;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicInteger;
public class TextViewIntegrationTest {
private static final String LOG_TAG = "TextViewIntegrationTest";
private static final String TOOLBAR_ITEM_LABEL = "TB@#%!";
private SimpleTextClassifier mSimpleTextClassifier;
@Rule
public ActivityScenarioRule<TextViewActivity> rule = new ActivityScenarioRule<>(
TextViewActivity.class);
private static float sOriginalAnimationDurationScale;
private static float sOriginalTransitionAnimationDurationScale;
private static final UiDevice sDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
@Before
public void setup() throws Exception {
Assume.assumeTrue(
ApplicationProvider.getApplicationContext().getPackageManager()
.hasSystemFeature(FEATURE_TOUCHSCREEN));
workAroundNotificationShadeWindowIssue();
mSimpleTextClassifier = new SimpleTextClassifier();
sDevice.wakeUp();
dismissKeyguard();
closeSystemDialog();
}
// Somehow there is a stale "NotificationShade" window from SysUI stealing the inputs.
// The window is in the "exiting" state and seems never finish exiting.
// The workaround here is to (hopefully) reset its state by expanding the notification panel
// and collapsing it again.
private void workAroundNotificationShadeWindowIssue() throws InterruptedException {
ShellUtils.runShellCommand("cmd statusbar expand-notifications");
Thread.sleep(1000);
ShellUtils.runShellCommand("cmd statusbar collapse");
Thread.sleep(1000);
}
private void dismissKeyguard() {
ShellUtils.runShellCommand("wm dismiss-keyguard");
}
private static void closeSystemDialog() {
ShellUtils.runShellCommand("am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS");
}
@BeforeClass
public static void disableAnimation() {
SystemUtil.runWithShellPermissionIdentity(() -> {
ContentResolver resolver =
ApplicationProvider.getApplicationContext().getContentResolver();
sOriginalAnimationDurationScale =
Settings.Global.getFloat(resolver, ANIMATOR_DURATION_SCALE, 1f);
Settings.Global.putFloat(resolver, ANIMATOR_DURATION_SCALE, 0);
sOriginalTransitionAnimationDurationScale =
Settings.Global.getFloat(resolver, TRANSITION_ANIMATION_SCALE, 1f);
Settings.Global.putFloat(resolver, TRANSITION_ANIMATION_SCALE, 0);
});
}
@AfterClass
public static void restoreAnimation() {
SystemUtil.runWithShellPermissionIdentity(() -> {
Settings.Global.putFloat(
ApplicationProvider.getApplicationContext().getContentResolver(),
ANIMATOR_DURATION_SCALE, sOriginalAnimationDurationScale);
Settings.Global.putFloat(
ApplicationProvider.getApplicationContext().getContentResolver(),
TRANSITION_ANIMATION_SCALE, sOriginalTransitionAnimationDurationScale);
});
}
@Test
public void smartLinkify() throws Exception {
ActivityScenario<TextViewActivity> scenario = rule.getScenario();
// Linkify the text.
final String TEXT = "Link: https://www.android.com";
AtomicInteger clickIndex = new AtomicInteger();
Spannable linkifiedText = createLinkifiedText(TEXT);
scenario.onActivity(activity -> {
TextView textView = activity.findViewById(R.id.textview);
textView.setText(linkifiedText);
textView.setTextClassifier(mSimpleTextClassifier);
textView.setMovementMethod(LinkMovementMethod.getInstance());
TextLinks.TextLinkSpan[] spans = linkifiedText.getSpans(0, TEXT.length(),
TextLinks.TextLinkSpan.class);
assertThat(spans).hasLength(1);
TextLinks.TextLinkSpan span = spans[0];
clickIndex.set(
(span.getTextLink().getStart() + span.getTextLink().getEnd()) / 2);
});
// To wait for the rendering of the activity to be completed, so that the upcoming click
// action will work.
Thread.sleep(2000);
onView(allOf(withId(R.id.textview), withText(TEXT))).check(matches(isDisplayed()));
// Click on the span.
Log.d(LOG_TAG, "clickIndex = " + clickIndex.get());
onView(withId(R.id.textview)).perform(TextViewActions.tapOnTextAtIndex(clickIndex.get()));
assertFloatingToolbarIsDisplayed();
}
@Test
public void smartSelection_suggestSelectionNotIncludeTextClassification() throws Exception {
Assume.assumeTrue(BuildCompat.isAtLeastS());
smartSelectionInternal();
assertThat(mSimpleTextClassifier.getClassifyTextInvocationCount()).isEqualTo(1);
}
@Test
public void smartSelection_suggestSelectionIncludeTextClassification() throws Exception {
Assume.assumeTrue(BuildCompat.isAtLeastS());
mSimpleTextClassifier.setIncludeTextClassification(true);
smartSelectionInternal();
assertThat(mSimpleTextClassifier.getClassifyTextInvocationCount()).isEqualTo(0);
}
@Test
@Ignore // Enable the test once b/187862341 is fixed.
public void smartSelection_cancelSelectionDoesNotInvokeClassifyText() throws Exception {
Assume.assumeTrue(BuildCompat.isAtLeastS());
smartSelectionInternal();
onView(withId(R.id.textview)).perform(TextViewActions.tapOnTextAtIndex(0));
Thread.sleep(1000);
assertThat(mSimpleTextClassifier.getClassifyTextInvocationCount()).isEqualTo(1);
}
private void smartSelectionInternal() {
ActivityScenario<TextViewActivity> scenario = rule.getScenario();
AtomicInteger clickIndex = new AtomicInteger();
// 0123456789
final String TEXT = "Link: https://www.android.com";
scenario.onActivity(activity -> {
TextView textView = activity.findViewById(R.id.textview);
textView.setTextIsSelectable(true);
textView.setText(TEXT);
textView.setTextClassifier(mSimpleTextClassifier);
clickIndex.set(9);
});
onView(allOf(withId(R.id.textview), withText(TEXT))).check(matches(isDisplayed()));
// Long press the url to perform smart selection.
Log.d(LOG_TAG, "clickIndex = " + clickIndex.get());
onView(withId(R.id.textview)).perform(
TextViewActions.longTapOnTextAtIndex(clickIndex.get()));
assertFloatingToolbarIsDisplayed();
}
private Spannable createLinkifiedText(CharSequence text) {
TextLinks.Request request = new TextLinks.Request.Builder(text)
.setEntityConfig(
new TextClassifier.EntityConfig.Builder()
.setIncludedTypes(Collections.singleton(TextClassifier.TYPE_URL))
.build())
.build();
TextLinks textLinks = mSimpleTextClassifier.generateLinks(request);
Spannable linkifiedText = new SpannableString(text);
int resultCode = textLinks.apply(
linkifiedText,
TextLinks.APPLY_STRATEGY_REPLACE,
/* spanFactory= */null);
assertThat(resultCode).isEqualTo(TextLinks.STATUS_LINKS_APPLIED);
return linkifiedText;
}
private static void assertFloatingToolbarIsDisplayed() {
// Simply check that the toolbar item is visible.
assertThat(sDevice.hasObject(By.text(TOOLBAR_ITEM_LABEL))).isTrue();
}
/**
* A {@link TextClassifier} that can only annotate the android.com url. Do not reuse the same
* instance across tests.
*/
private static class SimpleTextClassifier implements TextClassifier {
private static final String ANDROID_URL = "https://www.android.com";
private static final Icon NO_ICON = Icon.createWithData(new byte[0], 0, 0);
private boolean mSetIncludeTextClassification = false;
private int mClassifyTextInvocationCount = 0;
public void setIncludeTextClassification(boolean setIncludeTextClassification) {
mSetIncludeTextClassification = setIncludeTextClassification;
}
public int getClassifyTextInvocationCount() {
return mClassifyTextInvocationCount;
}
@Override
public TextSelection suggestSelection(TextSelection.Request request) {
int start = request.getText().toString().indexOf(ANDROID_URL);
if (start == -1) {
return new TextSelection.Builder(
request.getStartIndex(), request.getEndIndex())
.build();
}
TextSelection.Builder builder =
new TextSelection.Builder(start, start + ANDROID_URL.length())
.setEntityType(TextClassifier.TYPE_URL, 1.0f);
if (mSetIncludeTextClassification) {
builder.setTextClassification(createAndroidUrlTextClassification());
}
return builder.build();
}
@Override
public TextClassification classifyText(TextClassification.Request request) {
mClassifyTextInvocationCount += 1;
String spanText = request.getText().toString()
.substring(request.getStartIndex(), request.getEndIndex());
if (TextUtils.equals(ANDROID_URL, spanText)) {
return createAndroidUrlTextClassification();
}
return new TextClassification.Builder().build();
}
private TextClassification createAndroidUrlTextClassification() {
TextClassification.Builder builder =
new TextClassification.Builder().setText(ANDROID_URL);
builder.setEntityType(TextClassifier.TYPE_URL, 1.0f);
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse(ANDROID_URL));
PendingIntent pendingIntent = PendingIntent.getActivity(
ApplicationProvider.getApplicationContext(),
/* requestCode= */ 0,
intent,
PendingIntent.FLAG_IMMUTABLE);
RemoteAction remoteAction =
new RemoteAction(NO_ICON, TOOLBAR_ITEM_LABEL, "cont-descr", pendingIntent);
remoteAction.setShouldShowIcon(false);
builder.addAction(remoteAction);
return builder.build();
}
@Override
public TextLinks generateLinks(TextLinks.Request request) {
TextLinks.Builder builder = new TextLinks.Builder(request.getText().toString());
int index = request.getText().toString().indexOf(ANDROID_URL);
if (index == -1) {
return builder.build();
}
builder.addLink(index,
index + ANDROID_URL.length(),
Collections.singletonMap(TextClassifier.TYPE_URL, 1.0f));
return builder.build();
}
}
}