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