1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.view.textclassifier.cts; 18 19 import static android.content.pm.PackageManager.FEATURE_TOUCHSCREEN; 20 import static android.provider.Settings.Global.ANIMATOR_DURATION_SCALE; 21 import static android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE; 22 23 import static androidx.test.espresso.Espresso.onView; 24 import static androidx.test.espresso.assertion.ViewAssertions.matches; 25 import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; 26 import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; 27 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; 28 import static androidx.test.espresso.matcher.ViewMatchers.withId; 29 import static androidx.test.espresso.matcher.ViewMatchers.withTagValue; 30 import static androidx.test.espresso.matcher.ViewMatchers.withText; 31 32 import static com.google.common.truth.Truth.assertThat; 33 34 import static org.hamcrest.CoreMatchers.allOf; 35 import static org.hamcrest.CoreMatchers.is; 36 37 import android.app.PendingIntent; 38 import android.app.RemoteAction; 39 import android.content.ContentResolver; 40 import android.content.Intent; 41 import android.content.pm.PackageManager; 42 import android.graphics.drawable.Icon; 43 import android.net.Uri; 44 import android.os.RemoteException; 45 import android.provider.Settings; 46 import android.text.Spannable; 47 import android.text.SpannableString; 48 import android.text.TextUtils; 49 import android.text.method.LinkMovementMethod; 50 import android.util.Log; 51 import android.view.textclassifier.TextClassification; 52 import android.view.textclassifier.TextClassifier; 53 import android.view.textclassifier.TextLinks; 54 import android.view.textclassifier.TextSelection; 55 import android.widget.TextView; 56 57 import androidx.core.os.BuildCompat; 58 import androidx.test.core.app.ActivityScenario; 59 import androidx.test.core.app.ApplicationProvider; 60 import androidx.test.espresso.ViewInteraction; 61 import androidx.test.ext.junit.rules.ActivityScenarioRule; 62 import androidx.test.platform.app.InstrumentationRegistry; 63 import androidx.test.uiautomator.UiDevice; 64 65 import com.android.compatibility.common.util.ShellUtils; 66 import com.android.compatibility.common.util.SystemUtil; 67 68 import org.junit.AfterClass; 69 import org.junit.Assume; 70 import org.junit.Before; 71 import org.junit.BeforeClass; 72 import org.junit.Ignore; 73 import org.junit.Rule; 74 import org.junit.Test; 75 76 import java.io.IOException; 77 import java.util.Collections; 78 import java.util.concurrent.atomic.AtomicInteger; 79 80 public class TextViewIntegrationTest { 81 private final static String LOG_TAG = "TextViewIntegrationTest"; 82 private final static String TOOLBAR_TAG = "floating_toolbar"; 83 84 private SimpleTextClassifier mSimpleTextClassifier; 85 86 @Rule 87 public ActivityScenarioRule<TextViewActivity> rule = new ActivityScenarioRule<>( 88 TextViewActivity.class); 89 90 private static float sOriginalAnimationDurationScale; 91 private static float sOriginalTransitionAnimationDurationScale; 92 93 @Before setup()94 public void setup() throws Exception { 95 Assume.assumeTrue( 96 ApplicationProvider.getApplicationContext().getPackageManager() 97 .hasSystemFeature(FEATURE_TOUCHSCREEN)); 98 workAroundNotificationShadeWindowIssue(); 99 mSimpleTextClassifier = new SimpleTextClassifier(); 100 UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).wakeUp(); 101 dismissKeyguard(); 102 closeSystemDialog(); 103 } 104 105 // Somehow there is a stale "NotificationShade" window from SysUI stealing the inputs. 106 // The window is in the "exiting" state and seems never finish exiting. 107 // The workaround here is to (hopefully) reset its state by expanding the notification panel 108 // and collapsing it again. workAroundNotificationShadeWindowIssue()109 private void workAroundNotificationShadeWindowIssue() throws InterruptedException { 110 ShellUtils.runShellCommand("cmd statusbar expand-notifications"); 111 Thread.sleep(1000); 112 ShellUtils.runShellCommand("cmd statusbar collapse"); 113 Thread.sleep(1000); 114 } 115 dismissKeyguard()116 private void dismissKeyguard() { 117 ShellUtils.runShellCommand("wm dismiss-keyguard"); 118 } 119 closeSystemDialog()120 private static void closeSystemDialog() { 121 ShellUtils.runShellCommand("am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS"); 122 } 123 124 @BeforeClass disableAnimation()125 public static void disableAnimation() { 126 SystemUtil.runWithShellPermissionIdentity(() -> { 127 ContentResolver resolver = 128 ApplicationProvider.getApplicationContext().getContentResolver(); 129 sOriginalAnimationDurationScale = 130 Settings.Global.getFloat(resolver, ANIMATOR_DURATION_SCALE, 1f); 131 Settings.Global.putFloat(resolver, ANIMATOR_DURATION_SCALE, 0); 132 133 sOriginalTransitionAnimationDurationScale = 134 Settings.Global.getFloat(resolver, TRANSITION_ANIMATION_SCALE, 1f); 135 Settings.Global.putFloat(resolver, TRANSITION_ANIMATION_SCALE, 0); 136 }); 137 } 138 139 @AfterClass restoreAnimation()140 public static void restoreAnimation() { 141 SystemUtil.runWithShellPermissionIdentity(() -> { 142 Settings.Global.putFloat( 143 ApplicationProvider.getApplicationContext().getContentResolver(), 144 ANIMATOR_DURATION_SCALE, sOriginalAnimationDurationScale); 145 146 Settings.Global.putFloat( 147 ApplicationProvider.getApplicationContext().getContentResolver(), 148 TRANSITION_ANIMATION_SCALE, sOriginalTransitionAnimationDurationScale); 149 }); 150 } 151 152 @Test smartLinkify()153 public void smartLinkify() throws Exception { 154 ActivityScenario<TextViewActivity> scenario = rule.getScenario(); 155 // Linkify the text. 156 final String TEXT = "Link: https://www.android.com"; 157 AtomicInteger clickIndex = new AtomicInteger(); 158 Spannable linkifiedText = createLinkifiedText(TEXT); 159 scenario.onActivity(activity -> { 160 TextView textView = activity.findViewById(R.id.textview); 161 textView.setText(linkifiedText); 162 textView.setTextClassifier(mSimpleTextClassifier); 163 textView.setMovementMethod(LinkMovementMethod.getInstance()); 164 TextLinks.TextLinkSpan[] spans = linkifiedText.getSpans(0, TEXT.length(), 165 TextLinks.TextLinkSpan.class); 166 assertThat(spans).hasLength(1); 167 TextLinks.TextLinkSpan span = spans[0]; 168 clickIndex.set( 169 (span.getTextLink().getStart() + span.getTextLink().getEnd()) / 2); 170 }); 171 // To wait for the rendering of the activity to be completed, so that the upcoming click 172 // action will work. 173 Thread.sleep(2000); 174 onView(allOf(withId(R.id.textview), withText(TEXT))).check(matches(isDisplayed())); 175 // Click on the span. 176 Log.d(LOG_TAG, "clickIndex = " + clickIndex.get()); 177 onView(withId(R.id.textview)).perform(TextViewActions.tapOnTextAtIndex(clickIndex.get())); 178 179 assertFloatingToolbarIsDisplayed(); 180 assertFloatingToolbarContainsItem("Test"); 181 } 182 183 @Test smartSelection_suggestSelectionNotIncludeTextClassification()184 public void smartSelection_suggestSelectionNotIncludeTextClassification() throws Exception { 185 Assume.assumeTrue(BuildCompat.isAtLeastS()); 186 smartSelectionInternal(); 187 188 assertThat(mSimpleTextClassifier.getClassifyTextInvocationCount()).isEqualTo(1); 189 } 190 191 @Test smartSelection_suggestSelectionIncludeTextClassification()192 public void smartSelection_suggestSelectionIncludeTextClassification() throws Exception { 193 Assume.assumeTrue(BuildCompat.isAtLeastS()); 194 mSimpleTextClassifier.setIncludeTextClassification(true); 195 smartSelectionInternal(); 196 197 assertThat(mSimpleTextClassifier.getClassifyTextInvocationCount()).isEqualTo(0); 198 } 199 200 @Test 201 @Ignore // Enable the test once b/187862341 is fixed. smartSelection_cancelSelectionDoesNotInvokeClassifyText()202 public void smartSelection_cancelSelectionDoesNotInvokeClassifyText() throws Exception { 203 Assume.assumeTrue(BuildCompat.isAtLeastS()); 204 smartSelectionInternal(); 205 onView(withId(R.id.textview)).perform(TextViewActions.tapOnTextAtIndex(0)); 206 Thread.sleep(1000); 207 208 assertThat(mSimpleTextClassifier.getClassifyTextInvocationCount()).isEqualTo(1); 209 } 210 smartSelectionInternal()211 private void smartSelectionInternal() { 212 ActivityScenario<TextViewActivity> scenario = rule.getScenario(); 213 AtomicInteger clickIndex = new AtomicInteger(); 214 // 0123456789 215 final String TEXT = "Link: https://www.android.com"; 216 scenario.onActivity(activity -> { 217 TextView textView = activity.findViewById(R.id.textview); 218 textView.setTextIsSelectable(true); 219 textView.setText(TEXT); 220 textView.setTextClassifier(mSimpleTextClassifier); 221 clickIndex.set(9); 222 }); 223 onView(allOf(withId(R.id.textview), withText(TEXT))).check(matches(isDisplayed())); 224 225 // Long press the url to perform smart selection. 226 Log.d(LOG_TAG, "clickIndex = " + clickIndex.get()); 227 onView(withId(R.id.textview)).perform( 228 TextViewActions.longTapOnTextAtIndex(clickIndex.get())); 229 230 assertFloatingToolbarIsDisplayed(); 231 assertFloatingToolbarContainsItem("Test"); 232 } 233 createLinkifiedText(CharSequence text)234 private Spannable createLinkifiedText(CharSequence text) { 235 TextLinks.Request request = new TextLinks.Request.Builder(text) 236 .setEntityConfig( 237 new TextClassifier.EntityConfig.Builder() 238 .setIncludedTypes(Collections.singleton(TextClassifier.TYPE_URL)) 239 .build()) 240 .build(); 241 TextLinks textLinks = mSimpleTextClassifier.generateLinks(request); 242 Spannable linkifiedText = new SpannableString(text); 243 int resultCode = textLinks.apply( 244 linkifiedText, 245 TextLinks.APPLY_STRATEGY_REPLACE, 246 /* spanFactory= */null); 247 assertThat(resultCode).isEqualTo(TextLinks.STATUS_LINKS_APPLIED); 248 return linkifiedText; 249 } 250 onFloatingToolBar()251 private static ViewInteraction onFloatingToolBar() { 252 return onView(withTagValue(is(TOOLBAR_TAG))).inRoot(isPlatformPopup()); 253 } 254 assertFloatingToolbarIsDisplayed()255 private static void assertFloatingToolbarIsDisplayed() { 256 onFloatingToolBar().check(matches(isDisplayed())); 257 } 258 assertFloatingToolbarContainsItem(String itemLabel)259 private static void assertFloatingToolbarContainsItem(String itemLabel) { 260 onFloatingToolBar().check(matches(hasDescendant(withText(itemLabel)))); 261 } 262 263 /** 264 * A {@link TextClassifier} that can only annotate the android.com url. Do not reuse the same 265 * instance across tests. 266 */ 267 private static class SimpleTextClassifier implements TextClassifier { 268 private static final String ANDROID_URL = "https://www.android.com"; 269 private static final Icon NO_ICON = Icon.createWithData(new byte[0], 0, 0); 270 private boolean mSetIncludeTextClassification = false; 271 private int mClassifyTextInvocationCount = 0; 272 setIncludeTextClassification(boolean setIncludeTextClassification)273 public void setIncludeTextClassification(boolean setIncludeTextClassification) { 274 mSetIncludeTextClassification = setIncludeTextClassification; 275 } 276 getClassifyTextInvocationCount()277 public int getClassifyTextInvocationCount() { 278 return mClassifyTextInvocationCount; 279 } 280 281 @Override suggestSelection(TextSelection.Request request)282 public TextSelection suggestSelection(TextSelection.Request request) { 283 int start = request.getText().toString().indexOf(ANDROID_URL); 284 if (start == -1) { 285 return new TextSelection.Builder( 286 request.getStartIndex(), request.getEndIndex()) 287 .build(); 288 } 289 TextSelection.Builder builder = 290 new TextSelection.Builder(start, start + ANDROID_URL.length()) 291 .setEntityType(TextClassifier.TYPE_URL, 1.0f); 292 if (mSetIncludeTextClassification) { 293 builder.setTextClassification(createAndroidUrlTextClassification()); 294 } 295 return builder.build(); 296 } 297 298 @Override classifyText(TextClassification.Request request)299 public TextClassification classifyText(TextClassification.Request request) { 300 mClassifyTextInvocationCount += 1; 301 String spanText = request.getText().toString() 302 .substring(request.getStartIndex(), request.getEndIndex()); 303 if (TextUtils.equals(ANDROID_URL, spanText)) { 304 return createAndroidUrlTextClassification(); 305 } 306 return new TextClassification.Builder().build(); 307 } 308 createAndroidUrlTextClassification()309 private TextClassification createAndroidUrlTextClassification() { 310 TextClassification.Builder builder = 311 new TextClassification.Builder().setText(ANDROID_URL); 312 builder.setEntityType(TextClassifier.TYPE_URL, 1.0f); 313 314 Intent intent = new Intent(Intent.ACTION_VIEW); 315 intent.setData(Uri.parse(ANDROID_URL)); 316 PendingIntent pendingIntent = PendingIntent.getActivity( 317 ApplicationProvider.getApplicationContext(), 318 /* requestCode= */ 0, 319 intent, 320 PendingIntent.FLAG_IMMUTABLE); 321 322 RemoteAction remoteAction = 323 new RemoteAction(NO_ICON, "Test", "content description", pendingIntent); 324 remoteAction.setShouldShowIcon(false); 325 builder.addAction(remoteAction); 326 return builder.build(); 327 } 328 329 @Override generateLinks(TextLinks.Request request)330 public TextLinks generateLinks(TextLinks.Request request) { 331 TextLinks.Builder builder = new TextLinks.Builder(request.getText().toString()); 332 int index = request.getText().toString().indexOf(ANDROID_URL); 333 if (index == -1) { 334 return builder.build(); 335 } 336 builder.addLink(index, 337 index + ANDROID_URL.length(), 338 Collections.singletonMap(TextClassifier.TYPE_URL, 1.0f)); 339 return builder.build(); 340 } 341 } 342 } 343