1 // Copyright 2013 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.content.browser.input; 6 7 import android.graphics.Point; 8 import android.graphics.Rect; 9 import android.os.SystemClock; 10 import android.test.FlakyTest; 11 import android.test.suitebuilder.annotation.MediumTest; 12 import android.text.Editable; 13 import android.text.Selection; 14 import android.view.MotionEvent; 15 16 import org.chromium.base.ThreadUtils; 17 import org.chromium.base.test.util.Feature; 18 import org.chromium.base.test.util.UrlUtils; 19 import org.chromium.content.browser.ContentView; 20 import org.chromium.content.browser.RenderCoordinates; 21 import org.chromium.content.browser.test.util.Criteria; 22 import org.chromium.content.browser.test.util.CriteriaHelper; 23 import org.chromium.content.browser.test.util.DOMUtils; 24 import org.chromium.content.browser.test.util.TestCallbackHelperContainer; 25 import org.chromium.content.browser.test.util.TestInputMethodManagerWrapper; 26 import org.chromium.content.browser.test.util.TestTouchUtils; 27 import org.chromium.content.browser.test.util.TouchCommon; 28 import org.chromium.content_shell_apk.ContentShellTestBase; 29 30 import java.util.concurrent.Callable; 31 32 public class SelectionHandleTest extends ContentShellTestBase { 33 private static final String META_DISABLE_ZOOM = 34 "<meta name=\"viewport\" content=\"" + 35 "height=device-height," + 36 "width=device-width," + 37 "initial-scale=1.0," + 38 "minimum-scale=1.0," + 39 "maximum-scale=1.0," + 40 "\" />"; 41 42 // For these we use a tiny font-size so that we can be more strict on the expected handle 43 // positions. 44 private static final String TEXTAREA_ID = "textarea"; 45 private static final String TEXTAREA_DATA_URL = UrlUtils.encodeHtmlDataUri( 46 "<html><head>" + META_DISABLE_ZOOM + "</head><body>" + 47 "<textarea id=\"" + TEXTAREA_ID + 48 "\" cols=\"40\" rows=\"20\" style=\"font-size:6px\">" + 49 "L r m i s m d l r s t a e , c n e t t r a i i i i g e i , s d d e u m d t m o " + 50 "i c d d n u l b r e d l r m g a l q a U e i a m n m e i m q i n s r d " + 51 "e e c t t o u l m o a o i n s u a i u p x a o m d c n e u t D i a t " + 52 "i u e o o i r p e e d r t n o u t t v l t s e i l m o o e u u i t u l " + 53 "p r a u . x e t u s n o c e a c p d t t o p o d n , u t n u p q i " + 54 "o f c a e e u t o l t n m d s l b r m." + 55 "L r m i s m d l r s t a e , c n e t t r a i i i i g e i , s d d e u m d t m o " + 56 "i c d d n u l b r e d l r m g a l q a U e i a m n m e i m q i n s r d " + 57 "e e c t t o u l m o a o i n s u a i u p x a o m d c n e u t D i a t " + 58 "i u e o o i r p e e d r t n o u t t v l t s e i l m o o e u u i t u l " + 59 "p r a u . x e t u s n o c e a c p d t t o p o d n , u t n u p q i " + 60 "o f c a e e u t o l t n m d s l b r m." + 61 "</textarea>" + 62 "</body></html>"); 63 64 private static final String NONEDITABLE_DIV_ID = "noneditable"; 65 private static final String NONEDITABLE_DATA_URL = UrlUtils.encodeHtmlDataUri( 66 "<html><head>" + META_DISABLE_ZOOM + "</head><body>" + 67 "<div id=\"" + NONEDITABLE_DIV_ID + "\" style=\"width:200; font-size:6px\">" + 68 "L r m i s m d l r s t a e , c n e t t r a i i i i g e i , s d d e u m d t m o " + 69 "i c d d n u l b r e d l r m g a l q a U e i a m n m e i m q i n s r d " + 70 "e e c t t o u l m o a o i n s u a i u p x a o m d c n e u t D i a t " + 71 "i u e o o i r p e e d r t n o u t t v l t s e i l m o o e u u i t u l " + 72 "p r a u . x e t u s n o c e a c p d t t o p o d n , u t n u p q i " + 73 "o f c a e e u t o l t n m d s l b r m." + 74 "L r m i s m d l r s t a e , c n e t t r a i i i i g e i , s d d e u m d t m o " + 75 "i c d d n u l b r e d l r m g a l q a U e i a m n m e i m q i n s r d " + 76 "e e c t t o u l m o a o i n s u a i u p x a o m d c n e u t D i a t " + 77 "i u e o o i r p e e d r t n o u t t v l t s e i l m o o e u u i t u l " + 78 "p r a u . x e t u s n o c e a c p d t t o p o d n , u t n u p q i " + 79 "o f c a e e u t o l t n m d s l b r m." + 80 "</div>" + 81 "</body></html>"); 82 83 // TODO(cjhopman): These tolerances should be based on the actual width/height of a 84 // character/line. 85 private static final int HANDLE_POSITION_X_TOLERANCE_PIX = 20; 86 private static final int HANDLE_POSITION_Y_TOLERANCE_PIX = 30; 87 88 private enum TestPageType { 89 EDITABLE(TEXTAREA_ID, TEXTAREA_DATA_URL, true), 90 NONEDITABLE(NONEDITABLE_DIV_ID, NONEDITABLE_DATA_URL, false); 91 92 final String nodeId; 93 final String dataUrl; 94 final boolean selectionShouldBeEditable; 95 TestPageType(String nodeId, String dataUrl, boolean selectionShouldBeEditable)96 TestPageType(String nodeId, String dataUrl, boolean selectionShouldBeEditable) { 97 this.nodeId = nodeId; 98 this.dataUrl = dataUrl; 99 this.selectionShouldBeEditable = selectionShouldBeEditable; 100 } 101 } 102 launchWithUrl(String url)103 private void launchWithUrl(String url) throws Throwable { 104 launchContentShellWithUrl(url); 105 assertTrue("Page failed to load", waitForActiveShellToBeDoneLoading()); 106 assertWaitForPageScaleFactorMatch(1.0f); 107 108 // The TestInputMethodManagerWrapper intercepts showSoftInput so that a keyboard is never 109 // brought up. 110 getImeAdapter().setInputMethodManagerWrapper( 111 new TestInputMethodManagerWrapper(getContentViewCore())); 112 } 113 assertWaitForHasSelectionPosition()114 private void assertWaitForHasSelectionPosition() 115 throws Throwable { 116 assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { 117 @Override 118 public boolean isSatisfied() { 119 int start = getSelectionStart(); 120 int end = getSelectionEnd(); 121 return start > 0 && start == end; 122 } 123 })); 124 } 125 126 /** 127 * Verifies that when a long-press is performed on static page text, 128 * selection handles appear and that handles can be dragged to extend the 129 * selection. Does not check exact handle position as this will depend on 130 * screen size; instead, position is expected to be correct within 131 * HANDLE_POSITION_TOLERANCE_PIX. 132 * 133 * Test is flaky: crbug.com/290375 134 * @MediumTest 135 * @Feature({ "TextSelection", "Main" }) 136 */ 137 @FlakyTest testNoneditableSelectionHandles()138 public void testNoneditableSelectionHandles() throws Throwable { 139 doSelectionHandleTest(TestPageType.NONEDITABLE); 140 } 141 142 /** 143 * Verifies that when a long-press is performed on editable text (within a 144 * textarea), selection handles appear and that handles can be dragged to 145 * extend the selection. Does not check exact handle position as this will 146 * depend on screen size; instead, position is expected to be correct within 147 * HANDLE_POSITION_TOLERANCE_PIX. 148 */ 149 @MediumTest 150 @Feature({ "TextSelection" }) testEditableSelectionHandles()151 public void testEditableSelectionHandles() throws Throwable { 152 doSelectionHandleTest(TestPageType.EDITABLE); 153 } 154 doSelectionHandleTest(TestPageType pageType)155 private void doSelectionHandleTest(TestPageType pageType) throws Throwable { 156 launchWithUrl(pageType.dataUrl); 157 158 clickNodeToShowSelectionHandles(pageType.nodeId); 159 assertWaitForSelectionEditableEquals(pageType.selectionShouldBeEditable); 160 161 HandleView startHandle = getStartHandle(); 162 HandleView endHandle = getEndHandle(); 163 164 Rect nodeWindowBounds = getNodeBoundsPix(pageType.nodeId); 165 166 int leftX = (nodeWindowBounds.left + nodeWindowBounds.centerX()) / 2; 167 int centerX = nodeWindowBounds.centerX(); 168 int rightX = (nodeWindowBounds.right + nodeWindowBounds.centerX()) / 2; 169 170 int topY = (nodeWindowBounds.top + nodeWindowBounds.centerY()) / 2; 171 int centerY = nodeWindowBounds.centerY(); 172 int bottomY = (nodeWindowBounds.bottom + nodeWindowBounds.centerY()) / 2; 173 174 // Drag start handle up and to the left. The selection start should decrease. 175 dragHandleAndCheckSelectionChange(startHandle, leftX, topY, -1, 0); 176 // Drag end handle down and to the right. The selection end should increase. 177 dragHandleAndCheckSelectionChange(endHandle, rightX, bottomY, 0, 1); 178 // Drag start handle back to the middle. The selection start should increase. 179 dragHandleAndCheckSelectionChange(startHandle, centerX, centerY, 1, 0); 180 // Drag end handle up and to the left past the start handle. Both selection start and end 181 // should decrease. 182 dragHandleAndCheckSelectionChange(endHandle, leftX, topY, -1, -1); 183 // Drag start handle down and to the right past the end handle. Both selection start and end 184 // should increase. 185 dragHandleAndCheckSelectionChange(startHandle, rightX, bottomY, 1, 1); 186 187 clickToDismissHandles(); 188 } 189 dragHandleAndCheckSelectionChange(HandleView handle, int dragToX, int dragToY, final int expectedStartChange, final int expectedEndChange)190 private void dragHandleAndCheckSelectionChange(HandleView handle, int dragToX, int dragToY, 191 final int expectedStartChange, final int expectedEndChange) throws Throwable { 192 String initialText = getContentViewCore().getSelectedText(); 193 final int initialSelectionEnd = getSelectionEnd(); 194 final int initialSelectionStart = getSelectionStart(); 195 196 dragHandleTo(handle, dragToX, dragToY, 10); 197 assertWaitForEitherHandleNear(dragToX, dragToY); 198 199 if (getContentViewCore().isSelectionEditable()) { 200 assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { 201 @Override 202 public boolean isSatisfied() { 203 int startChange = getSelectionStart() - initialSelectionStart; 204 // TODO(cjhopman): Due to http://crbug.com/244633 we can't really assert that 205 // there is no change when we expect to be able to. 206 if (expectedStartChange != 0) { 207 if ((int) Math.signum(startChange) != expectedStartChange) return false; 208 } 209 210 int endChange = getSelectionEnd() - initialSelectionEnd; 211 if (expectedEndChange != 0) { 212 if ((int) Math.signum(endChange) != expectedEndChange) return false; 213 } 214 215 return true; 216 } 217 })); 218 } 219 220 assertWaitForHandleViewStopped(getStartHandle()); 221 assertWaitForHandleViewStopped(getEndHandle()); 222 } 223 assertWaitForSelectionEditableEquals(final boolean expected)224 private void assertWaitForSelectionEditableEquals(final boolean expected) throws Throwable { 225 assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { 226 @Override 227 public boolean isSatisfied() { 228 return getContentViewCore().isSelectionEditable() == expected; 229 } 230 })); 231 } 232 assertWaitForHandleViewStopped(final HandleView handle)233 private void assertWaitForHandleViewStopped(final HandleView handle) throws Throwable { 234 assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { 235 private Point position = new Point(-1, -1); 236 @Override 237 public boolean isSatisfied() { 238 Point lastPosition = position; 239 position = getHandlePosition(handle); 240 return !handle.isDragging() && 241 position.equals(lastPosition); 242 } 243 })); 244 } 245 246 /** 247 * Verifies that when a selection is made within static page text, that the 248 * contextual action bar of the correct type is displayed. Also verified 249 * that the bar disappears upon deselection. 250 */ 251 @MediumTest 252 @Feature({ "TextSelection" }) testNoneditableSelectionActionBar()253 public void testNoneditableSelectionActionBar() throws Throwable { 254 doSelectionActionBarTest(TestPageType.NONEDITABLE); 255 } 256 257 /** 258 * Verifies that when a selection is made within editable text, that the 259 * contextual action bar of the correct type is displayed. Also verified 260 * that the bar disappears upon deselection. 261 */ 262 @MediumTest 263 @Feature({ "TextSelection" }) testEditableSelectionActionBar()264 public void testEditableSelectionActionBar() throws Throwable { 265 doSelectionActionBarTest(TestPageType.EDITABLE); 266 } 267 doSelectionActionBarTest(TestPageType pageType)268 private void doSelectionActionBarTest(TestPageType pageType) throws Throwable { 269 launchWithUrl(pageType.dataUrl); 270 assertFalse(getContentViewCore().isSelectActionBarShowing()); 271 clickNodeToShowSelectionHandles(pageType.nodeId); 272 assertWaitForSelectActionBarShowingEquals(true); 273 clickToDismissHandles(); 274 assertWaitForSelectActionBarShowingEquals(false); 275 } 276 getHandlePosition(final HandleView handle)277 private static Point getHandlePosition(final HandleView handle) { 278 return ThreadUtils.runOnUiThreadBlockingNoException(new Callable<Point>() { 279 @Override 280 public Point call() { 281 return new Point(handle.getAdjustedPositionX(), handle.getAdjustedPositionY()); 282 } 283 }); 284 } 285 286 private static boolean isHandleNear(HandleView handle, int x, int y) { 287 Point position = getHandlePosition(handle); 288 return (Math.abs(position.x - x) < HANDLE_POSITION_X_TOLERANCE_PIX) && 289 (Math.abs(position.y - y) < HANDLE_POSITION_Y_TOLERANCE_PIX); 290 } 291 292 private void assertWaitForHandleNear(final HandleView handle, final int x, final int y) 293 throws Throwable { 294 assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { 295 @Override 296 public boolean isSatisfied() { 297 return isHandleNear(handle, x, y); 298 } 299 })); 300 } 301 302 private void assertWaitForEitherHandleNear(final int x, final int y) throws Throwable { 303 final HandleView startHandle = getStartHandle(); 304 final HandleView endHandle = getEndHandle(); 305 assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { 306 @Override 307 public boolean isSatisfied() { 308 return isHandleNear(startHandle, x, y) || isHandleNear(endHandle, x, y); 309 } 310 })); 311 } 312 313 private void assertWaitForHandlesShowingEquals(final boolean shouldBeShowing) throws Throwable { 314 assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { 315 @Override 316 public boolean isSatisfied() { 317 SelectionHandleController shc = 318 getContentViewCore().getSelectionHandleControllerForTest(); 319 boolean isShowing = shc != null && shc.isShowing(); 320 return shouldBeShowing == isShowing; 321 } 322 })); 323 } 324 325 326 private void dragHandleTo(final HandleView handle, final int dragToX, final int dragToY, 327 final int steps) throws Throwable { 328 ContentView view = getContentView(); 329 assertTrue(ThreadUtils.runOnUiThreadBlocking(new Callable<Boolean>() { 330 @Override 331 public Boolean call() { 332 int adjustedX = handle.getAdjustedPositionX(); 333 int adjustedY = handle.getAdjustedPositionY(); 334 int realX = handle.getPositionX(); 335 int realY = handle.getPositionY(); 336 337 int realDragToX = dragToX + (realX - adjustedX); 338 int realDragToY = dragToY + (realY - adjustedY); 339 340 ContentView view = getContentView(); 341 int[] fromLocation = TestTouchUtils.getAbsoluteLocationFromRelative( 342 view, realX, realY); 343 int[] toLocation = TestTouchUtils.getAbsoluteLocationFromRelative( 344 view, realDragToX, realDragToY); 345 346 long downTime = SystemClock.uptimeMillis(); 347 MotionEvent event = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 348 fromLocation[0], fromLocation[1], 0); 349 handle.dispatchTouchEvent(event); 350 351 if (!handle.isDragging()) return false; 352 353 for (int i = 0; i < steps; i++) { 354 float scale = (float) (i + 1) / steps; 355 int x = fromLocation[0] + (int) (scale * (toLocation[0] - fromLocation[0])); 356 int y = fromLocation[1] + (int) (scale * (toLocation[1] - fromLocation[1])); 357 long eventTime = SystemClock.uptimeMillis(); 358 event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, 359 x, y, 0); 360 handle.dispatchTouchEvent(event); 361 } 362 long upTime = SystemClock.uptimeMillis(); 363 event = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, 364 toLocation[0], toLocation[1], 0); 365 handle.dispatchTouchEvent(event); 366 367 return !handle.isDragging(); 368 } 369 })); 370 } 371 372 private Rect getNodeBoundsPix(String nodeId) throws Throwable { 373 Rect nodeBounds = DOMUtils.getNodeBounds(getContentView(), 374 new TestCallbackHelperContainer(getContentView()), nodeId); 375 376 RenderCoordinates renderCoordinates = getContentView().getRenderCoordinates(); 377 int offsetX = getContentView().getContentViewCore().getViewportSizeOffsetWidthPix(); 378 int offsetY = getContentView().getContentViewCore().getViewportSizeOffsetHeightPix(); 379 380 int left = (int) renderCoordinates.fromLocalCssToPix(nodeBounds.left) + offsetX; 381 int right = (int) renderCoordinates.fromLocalCssToPix(nodeBounds.right) + offsetX; 382 int top = (int) renderCoordinates.fromLocalCssToPix(nodeBounds.top) + offsetY; 383 int bottom = (int) renderCoordinates.fromLocalCssToPix(nodeBounds.bottom) + offsetY; 384 385 return new Rect(left, top, right, bottom); 386 } 387 388 private void clickNodeToShowSelectionHandles(String nodeId) throws Throwable { 389 Rect nodeWindowBounds = getNodeBoundsPix(nodeId); 390 391 TouchCommon touchCommon = new TouchCommon(this); 392 int centerX = nodeWindowBounds.centerX(); 393 int centerY = nodeWindowBounds.centerY(); 394 touchCommon.longPressView(getContentView(), centerX, centerY); 395 396 assertWaitForHandlesShowingEquals(true); 397 assertWaitForHandleViewStopped(getStartHandle()); 398 399 // No words wrap in the sample text so handles should be at the same y 400 // position. 401 assertEquals(getStartHandle().getPositionY(), getEndHandle().getPositionY()); 402 } 403 404 private void clickToDismissHandles() throws Throwable { 405 TestTouchUtils.sleepForDoubleTapTimeout(getInstrumentation()); 406 new TouchCommon(this).singleClickView(getContentView(), 0, 0); 407 assertWaitForHandlesShowingEquals(false); 408 } 409 410 private void assertWaitForSelectActionBarShowingEquals(final boolean shouldBeShowing) 411 throws InterruptedException { 412 assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { 413 @Override 414 public boolean isSatisfied() { 415 return shouldBeShowing == getContentViewCore().isSelectActionBarShowing(); 416 } 417 })); 418 } 419 420 public void assertWaitForHasInputConnection() { 421 try { 422 assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { 423 @Override 424 public boolean isSatisfied() { 425 return getContentViewCore().getInputConnectionForTest() != null; 426 } 427 })); 428 } catch (InterruptedException e) { 429 fail(); 430 } 431 } 432 433 private ImeAdapter getImeAdapter() { 434 return getContentViewCore().getImeAdapterForTest(); 435 } 436 437 private int getSelectionStart() { 438 return Selection.getSelectionStart(getEditable()); 439 } 440 441 private int getSelectionEnd() { 442 return Selection.getSelectionEnd(getEditable()); 443 } 444 445 private Editable getEditable() { 446 // We have to wait for the input connection (with the IME) to be created before accessing 447 // the ContentViewCore's editable. 448 assertWaitForHasInputConnection(); 449 return getContentViewCore().getEditableForTest(); 450 } 451 452 private HandleView getStartHandle() { 453 SelectionHandleController shc = getContentViewCore().getSelectionHandleControllerForTest(); 454 return shc.getStartHandleViewForTest(); 455 } 456 457 private HandleView getEndHandle() { 458 SelectionHandleController shc = getContentViewCore().getSelectionHandleControllerForTest(); 459 return shc.getEndHandleViewForTest(); 460 } 461 } 462