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