| // Copyright 2013 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.content.browser.input; |
| |
| import android.graphics.Point; |
| import android.graphics.Rect; |
| import android.os.SystemClock; |
| import android.test.FlakyTest; |
| import android.test.suitebuilder.annotation.MediumTest; |
| import android.text.Editable; |
| import android.text.Selection; |
| import android.view.MotionEvent; |
| import android.view.ViewGroup; |
| |
| import org.chromium.base.ThreadUtils; |
| import org.chromium.base.test.util.Feature; |
| import org.chromium.base.test.util.UrlUtils; |
| import org.chromium.content.browser.RenderCoordinates; |
| import org.chromium.content.browser.test.util.Criteria; |
| import org.chromium.content.browser.test.util.CriteriaHelper; |
| import org.chromium.content.browser.test.util.DOMUtils; |
| import org.chromium.content.browser.test.util.TestInputMethodManagerWrapper; |
| import org.chromium.content.browser.test.util.TestTouchUtils; |
| import org.chromium.content.browser.test.util.TouchCommon; |
| import org.chromium.content_shell_apk.ContentShellTestBase; |
| |
| import java.util.concurrent.Callable; |
| |
| public class SelectionHandleTest extends ContentShellTestBase { |
| private static final String META_DISABLE_ZOOM = |
| "<meta name=\"viewport\" content=\"" + |
| "height=device-height," + |
| "width=device-width," + |
| "initial-scale=1.0," + |
| "minimum-scale=1.0," + |
| "maximum-scale=1.0," + |
| "\" />"; |
| |
| // For these we use a tiny font-size so that we can be more strict on the expected handle |
| // positions. |
| private static final String TEXTAREA_ID = "textarea"; |
| private static final String TEXTAREA_DATA_URL = UrlUtils.encodeHtmlDataUri( |
| "<html><head>" + META_DISABLE_ZOOM + "</head><body>" + |
| "<textarea id=\"" + TEXTAREA_ID + |
| "\" cols=\"40\" rows=\"20\" style=\"font-size:6px\">" + |
| "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 " + |
| "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 " + |
| "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 " + |
| "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 " + |
| "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 " + |
| "o f c a e e u t o l t n m d s l b r m." + |
| "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 " + |
| "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 " + |
| "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 " + |
| "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 " + |
| "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 " + |
| "o f c a e e u t o l t n m d s l b r m." + |
| "</textarea>" + |
| "</body></html>"); |
| |
| private static final String NONEDITABLE_DIV_ID = "noneditable"; |
| private static final String NONEDITABLE_DATA_URL = UrlUtils.encodeHtmlDataUri( |
| "<html><head>" + META_DISABLE_ZOOM + "</head><body>" + |
| "<div id=\"" + NONEDITABLE_DIV_ID + "\" style=\"width:200; font-size:6px\">" + |
| "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 " + |
| "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 " + |
| "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 " + |
| "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 " + |
| "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 " + |
| "o f c a e e u t o l t n m d s l b r m." + |
| "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 " + |
| "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 " + |
| "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 " + |
| "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 " + |
| "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 " + |
| "o f c a e e u t o l t n m d s l b r m." + |
| "</div>" + |
| "</body></html>"); |
| |
| // TODO(cjhopman): These tolerances should be based on the actual width/height of a |
| // character/line. |
| private static final int HANDLE_POSITION_X_TOLERANCE_PIX = 20; |
| private static final int HANDLE_POSITION_Y_TOLERANCE_PIX = 30; |
| |
| private enum TestPageType { |
| EDITABLE(TEXTAREA_ID, TEXTAREA_DATA_URL, true), |
| NONEDITABLE(NONEDITABLE_DIV_ID, NONEDITABLE_DATA_URL, false); |
| |
| final String nodeId; |
| final String dataUrl; |
| final boolean selectionShouldBeEditable; |
| |
| TestPageType(String nodeId, String dataUrl, boolean selectionShouldBeEditable) { |
| this.nodeId = nodeId; |
| this.dataUrl = dataUrl; |
| this.selectionShouldBeEditable = selectionShouldBeEditable; |
| } |
| } |
| |
| private void launchWithUrl(String url) throws Throwable { |
| launchContentShellWithUrl(url); |
| assertTrue("Page failed to load", waitForActiveShellToBeDoneLoading()); |
| assertWaitForPageScaleFactorMatch(1.0f); |
| |
| // The TestInputMethodManagerWrapper intercepts showSoftInput so that a keyboard is never |
| // brought up. |
| getImeAdapter().setInputMethodManagerWrapper( |
| new TestInputMethodManagerWrapper(getContentViewCore())); |
| } |
| |
| private void assertWaitForHasSelectionPosition() |
| throws Throwable { |
| assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { |
| @Override |
| public boolean isSatisfied() { |
| int start = getSelectionStart(); |
| int end = getSelectionEnd(); |
| return start > 0 && start == end; |
| } |
| })); |
| } |
| |
| /** |
| * Verifies that when a long-press is performed on static page text, |
| * selection handles appear and that handles can be dragged to extend the |
| * selection. Does not check exact handle position as this will depend on |
| * screen size; instead, position is expected to be correct within |
| * HANDLE_POSITION_TOLERANCE_PIX. |
| * |
| * Test is flaky: crbug.com/290375 |
| * @MediumTest |
| * @Feature({ "TextSelection", "Main" }) |
| */ |
| @FlakyTest |
| public void testNoneditableSelectionHandles() throws Throwable { |
| doSelectionHandleTest(TestPageType.NONEDITABLE); |
| } |
| |
| /** |
| * Test is flaky: crbug.com/290375 |
| * @MediumTest |
| * @Feature({ "TextSelection", "Main" }) |
| */ |
| @FlakyTest |
| public void testUpdateContainerViewAndNoneditableSelectionHandles() throws Throwable { |
| launchWithUrl(TestPageType.NONEDITABLE.dataUrl); |
| replaceContainerView(); |
| doSelectionHandleTestUrlLaunched(TestPageType.NONEDITABLE); |
| } |
| |
| /** |
| * Verifies that when a long-press is performed on editable text (within a |
| * textarea), selection handles appear and that handles can be dragged to |
| * extend the selection. Does not check exact handle position as this will |
| * depend on screen size; instead, position is expected to be correct within |
| * HANDLE_POSITION_TOLERANCE_PIX. |
| */ |
| @MediumTest |
| @Feature({ "TextSelection" }) |
| public void testEditableSelectionHandles() throws Throwable { |
| doSelectionHandleTest(TestPageType.EDITABLE); |
| } |
| |
| @MediumTest |
| @Feature({ "TextSelection" }) |
| public void testUpdateContainerViewAndEditableSelectionHandles() throws Throwable { |
| launchWithUrl(TestPageType.EDITABLE.dataUrl); |
| replaceContainerView(); |
| doSelectionHandleTestUrlLaunched(TestPageType.EDITABLE); |
| } |
| |
| private void doSelectionHandleTest(TestPageType pageType) throws Throwable { |
| launchWithUrl(pageType.dataUrl); |
| doSelectionHandleTestUrlLaunched(pageType); |
| } |
| |
| private void doSelectionHandleTestUrlLaunched(TestPageType pageType) throws Throwable { |
| clickNodeToShowSelectionHandles(pageType.nodeId); |
| assertWaitForSelectionEditableEquals(pageType.selectionShouldBeEditable); |
| |
| HandleView startHandle = getStartHandle(); |
| HandleView endHandle = getEndHandle(); |
| |
| Rect nodeWindowBounds = getNodeBoundsPix(pageType.nodeId); |
| |
| int leftX = (nodeWindowBounds.left + nodeWindowBounds.centerX()) / 2; |
| int centerX = nodeWindowBounds.centerX(); |
| int rightX = (nodeWindowBounds.right + nodeWindowBounds.centerX()) / 2; |
| |
| int topY = (nodeWindowBounds.top + nodeWindowBounds.centerY()) / 2; |
| int centerY = nodeWindowBounds.centerY(); |
| int bottomY = (nodeWindowBounds.bottom + nodeWindowBounds.centerY()) / 2; |
| |
| // Drag start handle up and to the left. The selection start should decrease. |
| dragHandleAndCheckSelectionChange(startHandle, leftX, topY, -1, 0); |
| // Drag end handle down and to the right. The selection end should increase. |
| dragHandleAndCheckSelectionChange(endHandle, rightX, bottomY, 0, 1); |
| // Drag start handle back to the middle. The selection start should increase. |
| dragHandleAndCheckSelectionChange(startHandle, centerX, centerY, 1, 0); |
| // Drag end handle up and to the left past the start handle. Both selection start and end |
| // should decrease. |
| dragHandleAndCheckSelectionChange(endHandle, leftX, topY, -1, -1); |
| // Drag start handle down and to the right past the end handle. Both selection start and end |
| // should increase. |
| dragHandleAndCheckSelectionChange(startHandle, rightX, bottomY, 1, 1); |
| |
| clickToDismissHandles(); |
| } |
| |
| private void dragHandleAndCheckSelectionChange(HandleView handle, int dragToX, int dragToY, |
| final int expectedStartChange, final int expectedEndChange) throws Throwable { |
| String initialText = getContentViewCore().getSelectedText(); |
| final int initialSelectionEnd = getSelectionEnd(); |
| final int initialSelectionStart = getSelectionStart(); |
| |
| dragHandleTo(handle, dragToX, dragToY, 10); |
| assertWaitForEitherHandleNear(dragToX, dragToY); |
| |
| if (getContentViewCore().isSelectionEditable()) { |
| assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { |
| @Override |
| public boolean isSatisfied() { |
| int startChange = getSelectionStart() - initialSelectionStart; |
| // TODO(cjhopman): Due to http://crbug.com/244633 we can't really assert that |
| // there is no change when we expect to be able to. |
| if (expectedStartChange != 0) { |
| if ((int) Math.signum(startChange) != expectedStartChange) return false; |
| } |
| |
| int endChange = getSelectionEnd() - initialSelectionEnd; |
| if (expectedEndChange != 0) { |
| if ((int) Math.signum(endChange) != expectedEndChange) return false; |
| } |
| |
| return true; |
| } |
| })); |
| } |
| |
| assertWaitForHandleViewStopped(getStartHandle()); |
| assertWaitForHandleViewStopped(getEndHandle()); |
| } |
| |
| private void assertWaitForSelectionEditableEquals(final boolean expected) throws Throwable { |
| assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { |
| @Override |
| public boolean isSatisfied() { |
| return getContentViewCore().isSelectionEditable() == expected; |
| } |
| })); |
| } |
| |
| private void assertWaitForHandleViewStopped(final HandleView handle) throws Throwable { |
| assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { |
| private Point position = new Point(-1, -1); |
| @Override |
| public boolean isSatisfied() { |
| Point lastPosition = position; |
| position = getHandlePosition(handle); |
| return !handle.isDragging() && |
| position.equals(lastPosition); |
| } |
| })); |
| } |
| |
| /** |
| * Verifies that when a selection is made within static page text, that the |
| * contextual action bar of the correct type is displayed. Also verified |
| * that the bar disappears upon deselection. |
| */ |
| @MediumTest |
| @Feature({ "TextSelection" }) |
| public void testNoneditableSelectionActionBar() throws Throwable { |
| doSelectionActionBarTest(TestPageType.NONEDITABLE); |
| } |
| |
| /** |
| * Verifies that when a selection is made within editable text, that the |
| * contextual action bar of the correct type is displayed. Also verified |
| * that the bar disappears upon deselection. |
| */ |
| @MediumTest |
| @Feature({ "TextSelection" }) |
| public void testEditableSelectionActionBar() throws Throwable { |
| doSelectionActionBarTest(TestPageType.EDITABLE); |
| } |
| |
| private void doSelectionActionBarTest(TestPageType pageType) throws Throwable { |
| launchWithUrl(pageType.dataUrl); |
| assertFalse(getContentViewCore().isSelectActionBarShowing()); |
| clickNodeToShowSelectionHandles(pageType.nodeId); |
| assertWaitForSelectActionBarShowingEquals(true); |
| clickToDismissHandles(); |
| assertWaitForSelectActionBarShowingEquals(false); |
| } |
| |
| private static Point getHandlePosition(final HandleView handle) { |
| return ThreadUtils.runOnUiThreadBlockingNoException(new Callable<Point>() { |
| @Override |
| public Point call() { |
| return new Point(handle.getAdjustedPositionX(), handle.getAdjustedPositionY()); |
| } |
| }); |
| } |
| |
| private static boolean isHandleNear(HandleView handle, int x, int y) { |
| Point position = getHandlePosition(handle); |
| return (Math.abs(position.x - x) < HANDLE_POSITION_X_TOLERANCE_PIX) && |
| (Math.abs(position.y - y) < HANDLE_POSITION_Y_TOLERANCE_PIX); |
| } |
| |
| private void assertWaitForHandleNear(final HandleView handle, final int x, final int y) |
| throws Throwable { |
| assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { |
| @Override |
| public boolean isSatisfied() { |
| return isHandleNear(handle, x, y); |
| } |
| })); |
| } |
| |
| private void assertWaitForEitherHandleNear(final int x, final int y) throws Throwable { |
| final HandleView startHandle = getStartHandle(); |
| final HandleView endHandle = getEndHandle(); |
| assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { |
| @Override |
| public boolean isSatisfied() { |
| return isHandleNear(startHandle, x, y) || isHandleNear(endHandle, x, y); |
| } |
| })); |
| } |
| |
| private void assertWaitForHandlesShowingEquals(final boolean shouldBeShowing) throws Throwable { |
| assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { |
| @Override |
| public boolean isSatisfied() { |
| SelectionHandleController shc = |
| getContentViewCore().getSelectionHandleControllerForTest(); |
| boolean isShowing = shc != null && shc.isShowing(); |
| return shouldBeShowing == isShowing; |
| } |
| })); |
| } |
| |
| |
| private void dragHandleTo(final HandleView handle, final int dragToX, final int dragToY, |
| final int steps) throws Throwable { |
| assertTrue(ThreadUtils.runOnUiThreadBlocking(new Callable<Boolean>() { |
| @Override |
| public Boolean call() { |
| int adjustedX = handle.getAdjustedPositionX(); |
| int adjustedY = handle.getAdjustedPositionY(); |
| int realX = handle.getPositionX(); |
| int realY = handle.getPositionY(); |
| |
| int realDragToX = dragToX + (realX - adjustedX); |
| int realDragToY = dragToY + (realY - adjustedY); |
| |
| ViewGroup view = getContentViewCore().getContainerView(); |
| int[] fromLocation = TestTouchUtils.getAbsoluteLocationFromRelative( |
| view, realX, realY); |
| int[] toLocation = TestTouchUtils.getAbsoluteLocationFromRelative( |
| view, realDragToX, realDragToY); |
| |
| long downTime = SystemClock.uptimeMillis(); |
| MotionEvent event = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, |
| fromLocation[0], fromLocation[1], 0); |
| handle.dispatchTouchEvent(event); |
| |
| if (!handle.isDragging()) return false; |
| |
| for (int i = 0; i < steps; i++) { |
| float scale = (float) (i + 1) / steps; |
| int x = fromLocation[0] + (int) (scale * (toLocation[0] - fromLocation[0])); |
| int y = fromLocation[1] + (int) (scale * (toLocation[1] - fromLocation[1])); |
| long eventTime = SystemClock.uptimeMillis(); |
| event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, |
| x, y, 0); |
| handle.dispatchTouchEvent(event); |
| } |
| long upTime = SystemClock.uptimeMillis(); |
| event = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, |
| toLocation[0], toLocation[1], 0); |
| handle.dispatchTouchEvent(event); |
| |
| return !handle.isDragging(); |
| } |
| })); |
| } |
| |
| private Rect getNodeBoundsPix(String nodeId) throws Throwable { |
| Rect nodeBounds = DOMUtils.getNodeBounds(getContentViewCore(), nodeId); |
| |
| RenderCoordinates renderCoordinates = getContentViewCore().getRenderCoordinates(); |
| int offsetX = getContentViewCore().getViewportSizeOffsetWidthPix(); |
| int offsetY = getContentViewCore().getViewportSizeOffsetHeightPix(); |
| |
| int left = (int) renderCoordinates.fromLocalCssToPix(nodeBounds.left) + offsetX; |
| int right = (int) renderCoordinates.fromLocalCssToPix(nodeBounds.right) + offsetX; |
| int top = (int) renderCoordinates.fromLocalCssToPix(nodeBounds.top) + offsetY; |
| int bottom = (int) renderCoordinates.fromLocalCssToPix(nodeBounds.bottom) + offsetY; |
| |
| return new Rect(left, top, right, bottom); |
| } |
| |
| private void clickNodeToShowSelectionHandles(String nodeId) throws Throwable { |
| Rect nodeWindowBounds = getNodeBoundsPix(nodeId); |
| |
| TouchCommon touchCommon = new TouchCommon(this); |
| int centerX = nodeWindowBounds.centerX(); |
| int centerY = nodeWindowBounds.centerY(); |
| touchCommon.longPressView(getContentViewCore().getContainerView(), centerX, centerY); |
| |
| assertWaitForHandlesShowingEquals(true); |
| assertWaitForHandleViewStopped(getStartHandle()); |
| |
| // No words wrap in the sample text so handles should be at the same y |
| // position. |
| assertEquals(getStartHandle().getPositionY(), getEndHandle().getPositionY()); |
| } |
| |
| private void clickToDismissHandles() throws Throwable { |
| TestTouchUtils.sleepForDoubleTapTimeout(getInstrumentation()); |
| new TouchCommon(this).singleClickView(getContentViewCore().getContainerView(), 0, 0); |
| assertWaitForHandlesShowingEquals(false); |
| } |
| |
| private void assertWaitForSelectActionBarShowingEquals(final boolean shouldBeShowing) |
| throws InterruptedException { |
| assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { |
| @Override |
| public boolean isSatisfied() { |
| return shouldBeShowing == getContentViewCore().isSelectActionBarShowing(); |
| } |
| })); |
| } |
| |
| public void assertWaitForHasInputConnection() { |
| try { |
| assertTrue(CriteriaHelper.pollForCriteria(new Criteria() { |
| @Override |
| public boolean isSatisfied() { |
| return getContentViewCore().getInputConnectionForTest() != null; |
| } |
| })); |
| } catch (InterruptedException e) { |
| fail(); |
| } |
| } |
| |
| private ImeAdapter getImeAdapter() { |
| return getContentViewCore().getImeAdapterForTest(); |
| } |
| |
| private int getSelectionStart() { |
| return Selection.getSelectionStart(getEditable()); |
| } |
| |
| private int getSelectionEnd() { |
| return Selection.getSelectionEnd(getEditable()); |
| } |
| |
| private Editable getEditable() { |
| // We have to wait for the input connection (with the IME) to be created before accessing |
| // the ContentViewCore's editable. |
| assertWaitForHasInputConnection(); |
| return getContentViewCore().getEditableForTest(); |
| } |
| |
| private HandleView getStartHandle() { |
| SelectionHandleController shc = getContentViewCore().getSelectionHandleControllerForTest(); |
| return shc.getStartHandleViewForTest(); |
| } |
| |
| private HandleView getEndHandle() { |
| SelectionHandleController shc = getContentViewCore().getSelectionHandleControllerForTest(); |
| return shc.getEndHandleViewForTest(); |
| } |
| } |