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