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