Home | History | Annotate | Download | only in cts
      1 /**
      2  * Copyright (C) 2016 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
      5  * in compliance with the License. You may obtain a copy of the License at
      6  *
      7  * http://www.apache.org/licenses/LICENSE-2.0
      8  *
      9  * Unless required by applicable law or agreed to in writing, software distributed under the
     10  * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
     11  * express or implied. See the License for the specific language governing permissions and
     12  * limitations under the License.
     13  */
     14 
     15 package android.accessibilityservice.cts;
     16 
     17 import static android.accessibilityservice.cts.utils.ActivityLaunchUtils.launchActivityAndWaitForItToBeOnscreen;
     18 import static android.accessibilityservice.cts.utils.AsyncUtils.DEFAULT_TIMEOUT_MS;
     19 import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH;
     20 import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX;
     21 import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY;
     22 
     23 import static org.junit.Assert.assertEquals;
     24 import static org.junit.Assert.assertFalse;
     25 import static org.junit.Assert.assertNotNull;
     26 import static org.junit.Assert.assertNull;
     27 import static org.junit.Assert.assertTrue;
     28 import static org.mockito.Mockito.mock;
     29 import static org.mockito.Mockito.timeout;
     30 import static org.mockito.Mockito.times;
     31 import static org.mockito.Mockito.verify;
     32 
     33 import android.accessibilityservice.cts.R;
     34 import android.accessibilityservice.cts.activities.AccessibilityTextTraversalActivity;
     35 import android.app.Instrumentation;
     36 import android.app.UiAutomation;
     37 import android.graphics.RectF;
     38 import android.os.Bundle;
     39 import android.os.Message;
     40 import android.os.Parcelable;
     41 import android.text.SpannableString;
     42 import android.text.Spanned;
     43 import android.text.TextUtils;
     44 import android.text.style.ClickableSpan;
     45 import android.text.style.URLSpan;
     46 import android.view.View;
     47 import android.view.accessibility.AccessibilityManager;
     48 import android.view.accessibility.AccessibilityNodeInfo;
     49 import android.view.accessibility.AccessibilityNodeProvider;
     50 import android.view.accessibility.AccessibilityRequestPreparer;
     51 import android.widget.EditText;
     52 import android.widget.TextView;
     53 
     54 import androidx.test.InstrumentationRegistry;
     55 import androidx.test.rule.ActivityTestRule;
     56 import androidx.test.runner.AndroidJUnit4;
     57 
     58 import org.junit.AfterClass;
     59 import org.junit.Before;
     60 import org.junit.BeforeClass;
     61 import org.junit.Rule;
     62 import org.junit.Test;
     63 import org.junit.runner.RunWith;
     64 
     65 import java.util.Arrays;
     66 import java.util.List;
     67 import java.util.concurrent.atomic.AtomicBoolean;
     68 import java.util.concurrent.atomic.AtomicReference;
     69 
     70 /**
     71  * Test cases for actions taken on text views.
     72  */
     73 @RunWith(AndroidJUnit4.class)
     74 public class AccessibilityTextActionTest {
     75     private static Instrumentation sInstrumentation;
     76     private static UiAutomation sUiAutomation;
     77     final Object mClickableSpanCallbackLock = new Object();
     78     final AtomicBoolean mClickableSpanCalled = new AtomicBoolean(false);
     79 
     80 
     81     private AccessibilityTextTraversalActivity mActivity;
     82 
     83     @Rule
     84     public ActivityTestRule<AccessibilityTextTraversalActivity> mActivityRule =
     85             new ActivityTestRule<>(AccessibilityTextTraversalActivity.class, false, false);
     86 
     87     @BeforeClass
     88     public static void oneTimeSetup() throws Exception {
     89         sInstrumentation = InstrumentationRegistry.getInstrumentation();
     90         sUiAutomation = sInstrumentation.getUiAutomation();
     91     }
     92 
     93     @Before
     94     public void setUp() throws Exception {
     95         mActivity = launchActivityAndWaitForItToBeOnscreen(
     96                 sInstrumentation, sUiAutomation, mActivityRule);
     97         mClickableSpanCalled.set(false);
     98     }
     99 
    100     @AfterClass
    101     public static void postTestTearDown() {
    102         sUiAutomation.destroy();
    103     }
    104 
    105     @Test
    106     public void testNotEditableTextView_shouldNotExposeOrRespondToSetTextAction() {
    107         final TextView textView = (TextView) mActivity.findViewById(R.id.text);
    108         makeTextViewVisibleAndSetText(textView, mActivity.getString(R.string.a_b));
    109 
    110         final AccessibilityNodeInfo text = sUiAutomation.getRootInActiveWindow()
    111                 .findAccessibilityNodeInfosByText(mActivity.getString(R.string.a_b)).get(0);
    112 
    113         assertFalse("Standard text view should not support SET_TEXT", text.getActionList()
    114                 .contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_TEXT));
    115         assertEquals("Standard text view should not support SET_TEXT", 0,
    116                 text.getActions() & AccessibilityNodeInfo.ACTION_SET_TEXT);
    117         Bundle args = new Bundle();
    118         args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
    119                 mActivity.getString(R.string.text_input_blah));
    120         assertFalse(text.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args));
    121 
    122         sInstrumentation.waitForIdleSync();
    123         assertTrue("Text view should not update on failed set text",
    124                 TextUtils.equals(mActivity.getString(R.string.a_b), textView.getText()));
    125     }
    126 
    127     @Test
    128     public void testEditableTextView_shouldExposeAndRespondToSetTextAction() {
    129         final TextView textView = (TextView) mActivity.findViewById(R.id.text);
    130 
    131         sInstrumentation.runOnMainSync(new Runnable() {
    132             @Override
    133             public void run() {
    134                 textView.setVisibility(View.VISIBLE);
    135                 textView.setText(mActivity.getString(R.string.a_b), TextView.BufferType.EDITABLE);
    136             }
    137         });
    138 
    139         final AccessibilityNodeInfo text = sUiAutomation.getRootInActiveWindow()
    140                 .findAccessibilityNodeInfosByText(mActivity.getString(R.string.a_b)).get(0);
    141 
    142         assertTrue("Editable text view should support SET_TEXT", text.getActionList()
    143                 .contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_TEXT));
    144         assertEquals("Editable text view should support SET_TEXT",
    145                 AccessibilityNodeInfo.ACTION_SET_TEXT,
    146                 text.getActions() & AccessibilityNodeInfo.ACTION_SET_TEXT);
    147 
    148         Bundle args = new Bundle();
    149         String textToSet = mActivity.getString(R.string.text_input_blah);
    150         args.putCharSequence(
    151                 AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, textToSet);
    152 
    153         assertTrue(text.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args));
    154 
    155         sInstrumentation.waitForIdleSync();
    156         assertTrue("Editable text should update on set text",
    157                 TextUtils.equals(textToSet, textView.getText()));
    158     }
    159 
    160     @Test
    161     public void testEditText_shouldExposeAndRespondToSetTextAction() {
    162         final EditText editText = (EditText) mActivity.findViewById(R.id.edit);
    163         makeTextViewVisibleAndSetText(editText, mActivity.getString(R.string.a_b));
    164 
    165         final AccessibilityNodeInfo text = sUiAutomation.getRootInActiveWindow()
    166                 .findAccessibilityNodeInfosByText(mActivity.getString(R.string.a_b)).get(0);
    167 
    168         assertTrue("EditText should support SET_TEXT", text.getActionList()
    169                 .contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_TEXT));
    170         assertEquals("EditText view should support SET_TEXT",
    171                 AccessibilityNodeInfo.ACTION_SET_TEXT,
    172                 text.getActions() & AccessibilityNodeInfo.ACTION_SET_TEXT);
    173 
    174         Bundle args = new Bundle();
    175         String textToSet = mActivity.getString(R.string.text_input_blah);
    176         args.putCharSequence(
    177                 AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, textToSet);
    178 
    179         assertTrue(text.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args));
    180 
    181         sInstrumentation.waitForIdleSync();
    182         assertTrue("EditText should update on set text",
    183                 TextUtils.equals(textToSet, editText.getText()));
    184     }
    185 
    186     @Test
    187     public void testClickableSpan_shouldWorkFromAccessibilityService() {
    188         final TextView textView = (TextView) mActivity.findViewById(R.id.text);
    189         final ClickableSpan clickableSpan = new ClickableSpan() {
    190             @Override
    191             public void onClick(View widget) {
    192                 assertEquals("Clickable span called back on wrong View", textView, widget);
    193                 onClickCallback();
    194             }
    195         };
    196         final SpannableString textWithClickableSpan =
    197                 new SpannableString(mActivity.getString(R.string.a_b));
    198         textWithClickableSpan.setSpan(clickableSpan, 0, 1, 0);
    199         makeTextViewVisibleAndSetText(textView, textWithClickableSpan);
    200 
    201         ClickableSpan clickableSpanFromA11y
    202                 = findSingleSpanInViewWithText(R.string.a_b, ClickableSpan.class);
    203         clickableSpanFromA11y.onClick(null);
    204         assertOnClickCalled();
    205     }
    206 
    207     @Test
    208     public void testUrlSpan_shouldWorkFromAccessibilityService() {
    209         final TextView textView = (TextView) mActivity.findViewById(R.id.text);
    210         final String url = "com.android.some.random.url";
    211         final URLSpan urlSpan = new URLSpan(url) {
    212             @Override
    213             public void onClick(View widget) {
    214                 assertEquals("Url span called back on wrong View", textView, widget);
    215                 onClickCallback();
    216             }
    217         };
    218         final SpannableString textWithClickableSpan =
    219                 new SpannableString(mActivity.getString(R.string.a_b));
    220         textWithClickableSpan.setSpan(urlSpan, 0, 1, 0);
    221         makeTextViewVisibleAndSetText(textView, textWithClickableSpan);
    222 
    223         URLSpan urlSpanFromA11y = findSingleSpanInViewWithText(R.string.a_b, URLSpan.class);
    224         assertEquals(url, urlSpanFromA11y.getURL());
    225         urlSpanFromA11y.onClick(null);
    226 
    227         assertOnClickCalled();
    228     }
    229 
    230     @Test
    231     public void testTextLocations_textViewShouldProvideWhenRequested() {
    232         final TextView textView = (TextView) mActivity.findViewById(R.id.text);
    233         // Use text with a strong s, since that gets replaced with a double s for all caps.
    234         // That replacement requires us to properly handle the length of the string changing.
    235         String stringToSet = mActivity.getString(R.string.german_text_with_strong_s);
    236         makeTextViewVisibleAndSetText(textView, stringToSet);
    237         sInstrumentation.runOnMainSync(() -> textView.setAllCaps(true));
    238 
    239         final AccessibilityNodeInfo text = sUiAutomation.getRootInActiveWindow()
    240                 .findAccessibilityNodeInfosByText(stringToSet).get(0);
    241         List<String> textAvailableExtraData = text.getAvailableExtraData();
    242         assertTrue("Text view should offer text location to accessibility",
    243                 textAvailableExtraData.contains(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY));
    244         assertNull("Text locations should not be populated by default",
    245                 text.getExtras().get(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY));
    246         final Bundle getTextArgs = getTextLocationArguments(text);
    247         assertTrue("Refresh failed", text.refreshWithExtraData(
    248                 AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, getTextArgs));
    249         assertNodeContainsTextLocationInfoOnOneLineLTR(text);
    250     }
    251 
    252     @Test
    253     public void testTextLocations_textOutsideOfViewBounds_locationsShouldBeNull() {
    254         final EditText editText = (EditText) mActivity.findViewById(R.id.edit);
    255         makeTextViewVisibleAndSetText(editText, mActivity.getString(R.string.android_wiki));
    256 
    257         final AccessibilityNodeInfo text = sUiAutomation.getRootInActiveWindow()
    258                 .findAccessibilityNodeInfosByText(mActivity.getString(R.string.android_wiki)).get(0);
    259         List<String> textAvailableExtraData = text.getAvailableExtraData();
    260         assertTrue("Text view should offer text location to accessibility",
    261                 textAvailableExtraData.contains(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY));
    262         final Bundle getTextArgs = getTextLocationArguments(text);
    263         assertTrue("Refresh failed", text.refreshWithExtraData(
    264                 EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, getTextArgs));
    265         Parcelable[] parcelables = text.getExtras()
    266                 .getParcelableArray(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY);
    267         final RectF[] locationsBeforeScroll = Arrays.copyOf(
    268                 parcelables, parcelables.length, RectF[].class);
    269         assertEquals(text.getText().length(), locationsBeforeScroll.length);
    270         // The first character should be visible immediately
    271         assertFalse(locationsBeforeScroll[0].isEmpty());
    272         // Some of the characters should be off the screen, and thus have empty rects. Find the
    273         // break point
    274         int firstNullRectIndex = -1;
    275         for (int i = 1; i < locationsBeforeScroll.length; i++) {
    276             boolean isNull = locationsBeforeScroll[i] == null;
    277             if (firstNullRectIndex < 0) {
    278                 if (isNull) {
    279                     firstNullRectIndex = i;
    280                 }
    281             } else {
    282                 assertTrue(isNull);
    283             }
    284         }
    285 
    286         // Scroll down one line
    287         sInstrumentation.runOnMainSync(() -> {
    288             int[] viewPosition = new int[2];
    289             editText.getLocationOnScreen(viewPosition);
    290             final int oneLineDownY = (int) locationsBeforeScroll[0].bottom - viewPosition[1];
    291             editText.scrollTo(0, oneLineDownY + 1);
    292         });
    293 
    294         assertTrue("Refresh failed", text.refreshWithExtraData(
    295                 EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, getTextArgs));
    296         parcelables = text.getExtras()
    297                 .getParcelableArray(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY);
    298         final RectF[] locationsAfterScroll = Arrays.copyOf(
    299                 parcelables, parcelables.length, RectF[].class);
    300         // Now the first character should be off the screen
    301         assertNull(locationsAfterScroll[0]);
    302         // The first character that was off the screen should now be on it
    303         assertNotNull(locationsAfterScroll[firstNullRectIndex]);
    304     }
    305 
    306     @Test
    307     public void testTextLocations_withRequestPreparer_shouldHoldOffUntilReady() {
    308         final TextView textView = (TextView) mActivity.findViewById(R.id.text);
    309         makeTextViewVisibleAndSetText(textView, mActivity.getString(R.string.a_b));
    310 
    311         final AccessibilityNodeInfo text = sUiAutomation.getRootInActiveWindow()
    312                 .findAccessibilityNodeInfosByText(mActivity.getString(R.string.a_b)).get(0);
    313         final List<String> textAvailableExtraData = text.getAvailableExtraData();
    314         final Bundle getTextArgs = getTextLocationArguments(text);
    315 
    316         // Register a request preparer that will capture the message indicating that preparation
    317         // is complete
    318         final AtomicReference<Message> messageRefForPrepare = new AtomicReference<>(null);
    319         // Use mockito's asynchronous signaling
    320         Runnable mockRunnableForPrepare = mock(Runnable.class);
    321 
    322         AccessibilityManager a11yManager =
    323                 mActivity.getSystemService(AccessibilityManager.class);
    324         AccessibilityRequestPreparer requestPreparer = new AccessibilityRequestPreparer(
    325                 textView, AccessibilityRequestPreparer.REQUEST_TYPE_EXTRA_DATA) {
    326             @Override
    327             public void onPrepareExtraData(int virtualViewId,
    328                     String extraDataKey, Bundle args, Message preparationFinishedMessage) {
    329                 assertEquals(AccessibilityNodeProvider.HOST_VIEW_ID, virtualViewId);
    330                 assertEquals(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, extraDataKey);
    331                 assertEquals(0, args.getInt(EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX));
    332                 assertEquals(text.getText().length(),
    333                         args.getInt(EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH));
    334                 messageRefForPrepare.set(preparationFinishedMessage);
    335                 mockRunnableForPrepare.run();
    336             }
    337         };
    338         a11yManager.addAccessibilityRequestPreparer(requestPreparer);
    339         verify(mockRunnableForPrepare, times(0)).run();
    340 
    341         // Make the extra data request in another thread
    342         Runnable mockRunnableForData = mock(Runnable.class);
    343         new Thread(()-> {
    344                 assertTrue("Refresh failed", text.refreshWithExtraData(
    345                         EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, getTextArgs));
    346                 mockRunnableForData.run();
    347         }).start();
    348 
    349         // The extra data request should trigger the request preparer
    350         verify(mockRunnableForPrepare, timeout(DEFAULT_TIMEOUT_MS)).run();
    351         // Verify that the request for extra data didn't return. This is a bit racy, as we may still
    352         // not catch it if it does return prematurely, but it does provide some protection.
    353         sInstrumentation.waitForIdleSync();
    354         verify(mockRunnableForData, times(0)).run();
    355 
    356         // Declare preparation for the request complete, and verify that it runs to completion
    357         messageRefForPrepare.get().sendToTarget();
    358         verify(mockRunnableForData, timeout(DEFAULT_TIMEOUT_MS)).run();
    359         assertNodeContainsTextLocationInfoOnOneLineLTR(text);
    360         a11yManager.removeAccessibilityRequestPreparer(requestPreparer);
    361     }
    362 
    363     @Test
    364     public void testTextLocations_withUnresponsiveRequestPreparer_shouldTimeout() {
    365         final TextView textView = (TextView) mActivity.findViewById(R.id.text);
    366         makeTextViewVisibleAndSetText(textView, mActivity.getString(R.string.a_b));
    367 
    368         final AccessibilityNodeInfo text = sUiAutomation.getRootInActiveWindow()
    369                 .findAccessibilityNodeInfosByText(mActivity.getString(R.string.a_b)).get(0);
    370         final List<String> textAvailableExtraData = text.getAvailableExtraData();
    371         final Bundle getTextArgs = getTextLocationArguments(text);
    372 
    373         // Use mockito's asynchronous signaling
    374         Runnable mockRunnableForPrepare = mock(Runnable.class);
    375 
    376         AccessibilityManager a11yManager =
    377                 mActivity.getSystemService(AccessibilityManager.class);
    378         AccessibilityRequestPreparer requestPreparer = new AccessibilityRequestPreparer(
    379                 textView, AccessibilityRequestPreparer.REQUEST_TYPE_EXTRA_DATA) {
    380             @Override
    381             public void onPrepareExtraData(int virtualViewId,
    382                     String extraDataKey, Bundle args, Message preparationFinishedMessage) {
    383                 mockRunnableForPrepare.run();
    384             }
    385         };
    386         a11yManager.addAccessibilityRequestPreparer(requestPreparer);
    387         verify(mockRunnableForPrepare, times(0)).run();
    388 
    389         // Make the extra data request in another thread
    390         Runnable mockRunnableForData = mock(Runnable.class);
    391         new Thread(() -> {
    392             /*
    393              * Don't worry about the return value, as we're timing out. We're just making
    394              * sure that we don't hang the system.
    395              */
    396             text.refreshWithExtraData(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, getTextArgs);
    397             mockRunnableForData.run();
    398         }).start();
    399 
    400         // The extra data request should trigger the request preparer
    401         verify(mockRunnableForPrepare, timeout(DEFAULT_TIMEOUT_MS)).run();
    402 
    403         // Declare preparation for the request complete, and verify that it runs to completion
    404         verify(mockRunnableForData, timeout(DEFAULT_TIMEOUT_MS)).run();
    405         a11yManager.removeAccessibilityRequestPreparer(requestPreparer);
    406     }
    407 
    408     private Bundle getTextLocationArguments(AccessibilityNodeInfo info) {
    409         Bundle args = new Bundle();
    410         args.putInt(EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, 0);
    411         args.putInt(EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH, info.getText().length());
    412         return args;
    413     }
    414 
    415     private void assertNodeContainsTextLocationInfoOnOneLineLTR(AccessibilityNodeInfo info) {
    416         final Parcelable[] parcelables = info.getExtras()
    417                 .getParcelableArray(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY);
    418         final RectF[] locations = Arrays.copyOf(parcelables, parcelables.length, RectF[].class);
    419         assertEquals(info.getText().length(), locations.length);
    420         // The text should all be on one line, running left to right
    421         for (int i = 0; i < locations.length; i++) {
    422             assertEquals(locations[0].top, locations[i].top, 0.01);
    423             assertEquals(locations[0].bottom, locations[i].bottom, 0.01);
    424             assertTrue(locations[i].right > locations[i].left);
    425             if (i > 0) {
    426                 assertTrue(locations[i].left > locations[i-1].left);
    427             }
    428         }
    429     }
    430 
    431     private void onClickCallback() {
    432         synchronized (mClickableSpanCallbackLock) {
    433             mClickableSpanCalled.set(true);
    434             mClickableSpanCallbackLock.notifyAll();
    435         }
    436     }
    437 
    438     private void assertOnClickCalled() {
    439         synchronized (mClickableSpanCallbackLock) {
    440             long endTime = System.currentTimeMillis() + DEFAULT_TIMEOUT_MS;
    441             while (!mClickableSpanCalled.get() && (System.currentTimeMillis() < endTime)) {
    442                 try {
    443                     mClickableSpanCallbackLock.wait(endTime - System.currentTimeMillis());
    444                 } catch (InterruptedException e) {}
    445             }
    446         }
    447         assert(mClickableSpanCalled.get());
    448     }
    449 
    450     private <T> T findSingleSpanInViewWithText(int stringId, Class<T> type) {
    451         final AccessibilityNodeInfo text = sUiAutomation.getRootInActiveWindow()
    452                 .findAccessibilityNodeInfosByText(mActivity.getString(stringId)).get(0);
    453         CharSequence accessibilityTextWithSpan = text.getText();
    454         // The span should work even with the node recycled
    455         text.recycle();
    456         assertTrue(accessibilityTextWithSpan instanceof Spanned);
    457 
    458         T spans[] = ((Spanned) accessibilityTextWithSpan)
    459                 .getSpans(0, accessibilityTextWithSpan.length(), type);
    460         assertEquals(1, spans.length);
    461         return spans[0];
    462     }
    463 
    464     private void makeTextViewVisibleAndSetText(final TextView textView, final CharSequence text) {
    465         sInstrumentation.runOnMainSync(() -> {
    466             textView.setVisibility(View.VISIBLE);
    467             textView.setText(text);
    468         });
    469     }
    470 }
    471