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