Merge "Ability to start a cursor drag from anywhere in an editable TextView"
diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java
index eb0d9bf..3b6a009 100644
--- a/core/java/android/widget/Editor.java
+++ b/core/java/android/widget/Editor.java
@@ -148,8 +148,11 @@
 public class Editor {
     private static final String TAG = "Editor";
     private static final boolean DEBUG_UNDO = false;
-    // Specifies whether to use or not the magnifier when pressing the insertion or selection
-    // handles.
+
+    // Specifies whether to allow starting a cursor drag by dragging anywhere over the text.
+    @VisibleForTesting
+    public static boolean FLAG_ENABLE_CURSOR_DRAG = false;
+    // Specifies whether to use the magnifier when pressing the insertion or selection handles.
     private static final boolean FLAG_USE_MAGNIFIER = true;
 
     private static final int DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
@@ -204,7 +207,7 @@
     private final MetricsLogger mMetricsLogger = new MetricsLogger();
 
     // Cursor Controllers.
-    private InsertionPointCursorController mInsertionPointCursorController;
+    InsertionPointCursorController mInsertionPointCursorController;
     SelectionModifierCursorController mSelectionModifierCursorController;
     // Action mode used when text is selected or when actions on an insertion cursor are triggered.
     private ActionMode mTextActionMode;
@@ -1471,6 +1474,9 @@
         mTouchState.update(event, viewConfiguration);
         updateFloatingToolbarVisibility(event);
 
+        if (hasInsertionController()) {
+            getInsertionController().onTouchEvent(event);
+        }
         if (hasSelectionController()) {
             getSelectionController().onTouchEvent(event);
         }
@@ -5179,15 +5185,11 @@
 
                 case MotionEvent.ACTION_UP:
                     if (!offsetHasBeenChanged()) {
-                        final float deltaX = mLastDownRawX - ev.getRawX();
-                        final float deltaY = mLastDownRawY - ev.getRawY();
-                        final float distanceSquared = deltaX * deltaX + deltaY * deltaY;
-
-                        final ViewConfiguration viewConfiguration = ViewConfiguration.get(
-                                mTextView.getContext());
-                        final int touchSlop = viewConfiguration.getScaledTouchSlop();
-
-                        if (distanceSquared < touchSlop * touchSlop) {
+                        ViewConfiguration config = ViewConfiguration.get(mTextView.getContext());
+                        boolean isWithinTouchSlop = EditorTouchState.isDistanceWithin(
+                                mLastDownRawX, mLastDownRawY, ev.getRawX(), ev.getRawY(),
+                                config.getScaledTouchSlop());
+                        if (isWithinTouchSlop) {
                             // Tapping on the handle toggles the insertion action mode.
                             if (mTextActionMode != null) {
                                 stopTextActionMode();
@@ -5237,6 +5239,10 @@
             } else {
                 offset = -1;
             }
+            if (TextView.DEBUG_CURSOR) {
+                logCursor("InsertionHandleView: updatePosition", "x=%f, y=%f, offset=%d, line=%d",
+                        x, y, offset, mPreviousLineTouched);
+            }
             positionAtCursorOffset(offset, false, fromTouchScreen);
             if (mTextActionMode != null) {
                 invalidateActionMode();
@@ -5716,8 +5722,82 @@
         }
     }
 
-    private class InsertionPointCursorController implements CursorController {
+    class InsertionPointCursorController implements CursorController {
         private InsertionHandleView mHandle;
+        private boolean mIsDraggingCursor;
+
+        public void onTouchEvent(MotionEvent event) {
+            switch (event.getActionMasked()) {
+                case MotionEvent.ACTION_DOWN:
+                    mIsDraggingCursor = false;
+                    break;
+                case MotionEvent.ACTION_MOVE:
+                    if (mIsDraggingCursor) {
+                        performCursorDrag(event);
+                    } else if (FLAG_ENABLE_CURSOR_DRAG
+                                && mTextView.getLayout() != null
+                                && mTextView.isFocused()
+                                && mTouchState.isMovedEnoughForDrag()) {
+                        startCursorDrag(event);
+                    }
+                    break;
+                case MotionEvent.ACTION_UP:
+                case MotionEvent.ACTION_CANCEL:
+                    if (mIsDraggingCursor) {
+                        endCursorDrag(event);
+                    }
+                    break;
+            }
+        }
+
+        private void positionCursorDuringDrag(MotionEvent event) {
+            int line = mTextView.getLineAtCoordinate(event.getY());
+            int offset = mTextView.getOffsetAtCoordinate(line, event.getX());
+            int oldSelectionStart = mTextView.getSelectionStart();
+            int oldSelectionEnd = mTextView.getSelectionEnd();
+            if (offset == oldSelectionStart && offset == oldSelectionEnd) {
+                return;
+            }
+            Selection.setSelection((Spannable) mTextView.getText(), offset);
+            updateCursorPosition();
+            if (mHapticTextHandleEnabled) {
+                mTextView.performHapticFeedback(HapticFeedbackConstants.TEXT_HANDLE_MOVE);
+            }
+        }
+
+        private void startCursorDrag(MotionEvent event) {
+            if (TextView.DEBUG_CURSOR) {
+                logCursor("InsertionPointCursorController", "start cursor drag");
+            }
+            mIsDraggingCursor = true;
+            // We don't want the parent scroll/long-press handlers to take over while dragging.
+            mTextView.getParent().requestDisallowInterceptTouchEvent(true);
+            mTextView.cancelLongPress();
+            // Update the cursor position.
+            positionCursorDuringDrag(event);
+            // Show the cursor handle and magnifier.
+            show();
+            getHandle().removeHiderCallback();
+            getHandle().updateMagnifier(event);
+            // TODO(b/146555651): Figure out if suspendBlink() should be called here.
+        }
+
+        private void performCursorDrag(MotionEvent event) {
+            positionCursorDuringDrag(event);
+            getHandle().updateMagnifier(event);
+        }
+
+        private void endCursorDrag(MotionEvent event) {
+            if (TextView.DEBUG_CURSOR) {
+                logCursor("InsertionPointCursorController", "end cursor drag");
+            }
+            mIsDraggingCursor = false;
+            // Hide the magnifier and set the handle to be hidden after a delay.
+            getHandle().dismissMagnifier();
+            getHandle().hideAfterDelay();
+            // We're no longer dragging, so let the parent receive events.
+            mTextView.getParent().requestDisallowInterceptTouchEvent(false);
+        }
 
         public void show() {
             getHandle().show();
@@ -5725,15 +5805,19 @@
             final long durationSinceCutOrCopy =
                     SystemClock.uptimeMillis() - TextView.sLastCutCopyOrTextChangedTime;
 
-            // Cancel the single tap delayed runnable.
-            if (mInsertionActionModeRunnable != null
-                    && (mTouchState.isMultiTap() || isCursorInsideEasyCorrectionSpan())) {
-                mTextView.removeCallbacks(mInsertionActionModeRunnable);
+            if (mInsertionActionModeRunnable != null) {
+                if (mIsDraggingCursor
+                        || mTouchState.isMultiTap()
+                        || isCursorInsideEasyCorrectionSpan()) {
+                    // Cancel the runnable for showing the floating toolbar.
+                    mTextView.removeCallbacks(mInsertionActionModeRunnable);
+                }
             }
 
-            // Prepare and schedule the single tap runnable to run exactly after the double tap
-            // timeout has passed.
-            if (!mTouchState.isMultiTap()
+            // If the user recently performed a Cut or Copy action, we want to show the floating
+            // toolbar even for a single tap.
+            if (!mIsDraggingCursor
+                    && !mTouchState.isMultiTap()
                     && !isCursorInsideEasyCorrectionSpan()
                     && (durationSinceCutOrCopy < RECENT_CUT_COPY_DURATION_MS)) {
                 if (mTextActionMode == null) {
@@ -5751,7 +5835,9 @@
                 }
             }
 
-            getHandle().hideAfterDelay();
+            if (!mIsDraggingCursor) {
+                getHandle().hideAfterDelay();
+            }
 
             if (mSelectionModifierCursorController != null) {
                 mSelectionModifierCursorController.hide();
@@ -5797,7 +5883,7 @@
 
         @Override
         public boolean isCursorBeingModified() {
-            return mHandle != null && mHandle.isDragging();
+            return mIsDraggingCursor || (mHandle != null && mHandle.isDragging());
         }
 
         @Override
@@ -5959,7 +6045,6 @@
                 case MotionEvent.ACTION_MOVE:
                     final ViewConfiguration viewConfig = ViewConfiguration.get(
                             mTextView.getContext());
-                    final int touchSlop = viewConfig.getScaledTouchSlop();
 
                     if (mGestureStayedInTapRegion || mHaventMovedEnoughToStartDrag) {
                         final float deltaX = eventX - mTouchState.getLastDownX();
@@ -5973,6 +6058,7 @@
                         }
                         if (mHaventMovedEnoughToStartDrag) {
                             // We don't start dragging until the user has moved enough.
+                            int touchSlop = viewConfig.getScaledTouchSlop();
                             mHaventMovedEnoughToStartDrag =
                                     distanceSquared <= touchSlop * touchSlop;
                         }
diff --git a/core/java/android/widget/EditorTouchState.java b/core/java/android/widget/EditorTouchState.java
index f880939..3798d00 100644
--- a/core/java/android/widget/EditorTouchState.java
+++ b/core/java/android/widget/EditorTouchState.java
@@ -55,6 +55,8 @@
     private int mMultiTapStatus = MultiTapStatus.NONE;
     private boolean mMultiTapInSameArea;
 
+    private boolean mMovedEnoughForDrag;
+
     public float getLastDownX() {
         return mLastDownX;
     }
@@ -88,10 +90,14 @@
         return isMultiTap() && mMultiTapInSameArea;
     }
 
+    public boolean isMovedEnoughForDrag() {
+        return mMovedEnoughForDrag;
+    }
+
     /**
      * Updates the state based on the new event.
      */
-    public void update(MotionEvent event, ViewConfiguration viewConfiguration) {
+    public void update(MotionEvent event, ViewConfiguration config) {
         final int action = event.getActionMasked();
         if (action == MotionEvent.ACTION_DOWN) {
             final boolean isMouse = event.isFromSource(InputDevice.SOURCE_MOUSE);
@@ -105,11 +111,8 @@
                 } else {
                     mMultiTapStatus = MultiTapStatus.TRIPLE_CLICK;
                 }
-                final float deltaX = event.getX() - mLastDownX;
-                final float deltaY = event.getY() - mLastDownY;
-                final int distanceSquared = (int) ((deltaX * deltaX) + (deltaY * deltaY));
-                int doubleTapSlop = viewConfiguration.getScaledDoubleTapSlop();
-                mMultiTapInSameArea = distanceSquared < doubleTapSlop * doubleTapSlop;
+                mMultiTapInSameArea = isDistanceWithin(mLastDownX, mLastDownY,
+                        event.getX(), event.getY(), config.getScaledDoubleTapSlop());
                 if (TextView.DEBUG_CURSOR) {
                     String status = isDoubleTap() ? "double" : "triple";
                     String inSameArea = mMultiTapInSameArea ? "in same area" : "not in same area";
@@ -125,6 +128,7 @@
             }
             mLastDownX = event.getX();
             mLastDownY = event.getY();
+            mMovedEnoughForDrag = false;
         } else if (action == MotionEvent.ACTION_UP) {
             if (TextView.DEBUG_CURSOR) {
                 logCursor("EditorTouchState", "ACTION_UP");
@@ -132,6 +136,23 @@
             mLastUpX = event.getX();
             mLastUpY = event.getY();
             mLastUpMillis = event.getEventTime();
+            mMovedEnoughForDrag = false;
+        } else if (action == MotionEvent.ACTION_MOVE) {
+            mMovedEnoughForDrag = !isDistanceWithin(mLastDownX, mLastDownY,
+                    event.getX(), event.getY(), config.getScaledTouchSlop());
         }
     }
+
+    /**
+     * Returns true if the distance between the given coordinates is <= to the specified max.
+     * This is useful to be able to determine e.g. when the user's touch has moved enough in
+     * order to be considered a drag (no longer within touch slop).
+     */
+    public static boolean isDistanceWithin(float x1, float y1, float x2, float y2,
+            int maxDistance) {
+        float deltaX = x2 - x1;
+        float deltaY = y2 - y1;
+        float distanceSquared = (deltaX * deltaX) + (deltaY * deltaY);
+        return distanceSquared <= maxDistance * maxDistance;
+    }
 }
diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java
index ee169f2..43d9895 100644
--- a/core/java/android/widget/TextView.java
+++ b/core/java/android/widget/TextView.java
@@ -10870,6 +10870,10 @@
         if (mEditor != null) {
             mEditor.onTouchEvent(event);
 
+            if (mEditor.mInsertionPointCursorController != null
+                    && mEditor.mInsertionPointCursorController.isCursorBeingModified()) {
+                return true;
+            }
             if (mEditor.mSelectionModifierCursorController != null
                     && mEditor.mSelectionModifierCursorController.isDragAcceleratorActive()) {
                 return true;
diff --git a/core/tests/coretests/src/android/widget/EditorCursorDragTest.java b/core/tests/coretests/src/android/widget/EditorCursorDragTest.java
new file mode 100644
index 0000000..b4f2c91
--- /dev/null
+++ b/core/tests/coretests/src/android/widget/EditorCursorDragTest.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.widget;
+
+import static android.widget.espresso.TextViewActions.dragOnText;
+import static android.widget.espresso.TextViewAssertions.hasInsertionPointerAtIndex;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.replaceText;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+
+import android.app.Activity;
+import android.app.Instrumentation;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.SmallTest;
+import androidx.test.rule.ActivityTestRule;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.frameworks.coretests.R;
+
+import com.google.common.base.Strings;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class EditorCursorDragTest {
+    @Rule
+    public ActivityTestRule<TextViewActivity> mActivityRule = new ActivityTestRule<>(
+            TextViewActivity.class);
+
+    private boolean mOriginalFlagValue;
+    private Instrumentation mInstrumentation;
+    private Activity mActivity;
+
+    @Before
+    public void before() throws Throwable {
+        mInstrumentation = InstrumentationRegistry.getInstrumentation();
+        mActivity = mActivityRule.getActivity();
+        mOriginalFlagValue = Editor.FLAG_ENABLE_CURSOR_DRAG;
+        Editor.FLAG_ENABLE_CURSOR_DRAG = true;
+    }
+    @After
+    public void after() throws Throwable {
+        Editor.FLAG_ENABLE_CURSOR_DRAG = mOriginalFlagValue;
+    }
+
+    @Test
+    public void testCursorDrag_horizontal_whenTextViewContentsFitOnScreen() throws Throwable {
+        String text = "Hello world!";
+        onView(withId(R.id.textview)).perform(replaceText(text));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0));
+
+        // Drag left to right. The cursor should end up at the position where the finger is lifted.
+        onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("llo"), text.indexOf("!")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(11));
+
+        // Drag right to left. The cursor should end up at the position where the finger is lifted.
+        onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("!"), text.indexOf("llo")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(2));
+    }
+
+    @Test
+    public void testCursorDrag_horizontal_whenTextViewContentsLargerThanScreen() throws Throwable {
+        String text = "Hello world!"
+                + Strings.repeat("\n", 500) + "012345middle"
+                + Strings.repeat("\n", 10) + "012345last";
+        onView(withId(R.id.textview)).perform(replaceText(text));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0));
+
+        // Drag left to right. The cursor should end up at the position where the finger is lifted.
+        onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("llo"), text.indexOf("!")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(11));
+
+        // Drag right to left. The cursor should end up at the position where the finger is lifted.
+        onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("!"), text.indexOf("llo")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(2));
+    }
+
+    @Test
+    public void testCursorDrag_diagonal_whenTextViewContentsLargerThanScreen() throws Throwable {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 1; i <= 9; i++) {
+            sb.append("line").append(i).append("\n");
+        }
+        sb.append(Strings.repeat("0123456789\n\n", 500)).append("Last line");
+        String text = sb.toString();
+        onView(withId(R.id.textview)).perform(replaceText(text));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0));
+
+        // Drag along a diagonal path.
+        onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("line1"), text.indexOf("2")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("2")));
+
+        // Drag along a steeper diagonal path.
+        onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("line1"), text.indexOf("9")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("9")));
+
+        // Drag along an almost vertical path.
+        // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor.
+        onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("ne1"), text.indexOf("9")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("9")));
+
+        // Drag along a vertical path from line 1 to line 9.
+        // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor.
+        onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("e1"), text.indexOf("e9")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("e9")));
+
+        // Drag along a vertical path from line 9 to line 1.
+        // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor.
+        onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("e9"), text.indexOf("e1")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("e1")));
+    }
+
+    @Test
+    public void testCursorDrag_vertical_whenTextViewContentsFitOnScreen() throws Throwable {
+        String text = "012first\n\n" + Strings.repeat("012345\n\n", 10) + "012last";
+        onView(withId(R.id.textview)).perform(replaceText(text));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0));
+
+        // Drag down. Since neither the TextView nor its container require scrolling, the cursor
+        // drag should execute and the cursor should end up at the position where the finger is
+        // lifted.
+        onView(withId(R.id.textview)).perform(
+                dragOnText(text.indexOf("first"), text.indexOf("last")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.length() - 4));
+
+        // Drag up. Since neither the TextView nor its container require scrolling, the cursor
+        // drag should execute and the cursor should end up at the position where the finger is
+        // lifted.
+        onView(withId(R.id.textview)).perform(
+                dragOnText(text.indexOf("last"), text.indexOf("first")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(3));
+    }
+
+    @Test
+    public void testCursorDrag_vertical_whenTextViewContentsLargerThanScreen() throws Throwable {
+        String text = "012345first\n\n"
+                + Strings.repeat("0123456789\n\n", 10) + "012345middle"
+                + Strings.repeat("0123456789\n\n", 500) + "012345last";
+        onView(withId(R.id.textview)).perform(replaceText(text));
+        int initialCursorPosition = 0;
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(initialCursorPosition));
+
+        // Drag up.
+        // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor.
+        onView(withId(R.id.textview)).perform(
+                dragOnText(text.indexOf("middle"), text.indexOf("first")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("first")));
+
+        // Drag down.
+        // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor.
+        onView(withId(R.id.textview)).perform(
+                dragOnText(text.indexOf("first"), text.indexOf("middle")));
+        onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("middle")));
+    }
+}
diff --git a/core/tests/coretests/src/android/widget/EditorTouchStateTest.java b/core/tests/coretests/src/android/widget/EditorTouchStateTest.java
index 6d50e3a..6adb1b8 100644
--- a/core/tests/coretests/src/android/widget/EditorTouchStateTest.java
+++ b/core/tests/coretests/src/android/widget/EditorTouchStateTest.java
@@ -16,6 +16,9 @@
 
 package android.widget;
 
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.is;
 
@@ -50,19 +53,19 @@
         long event1Time = 1000;
         MotionEvent event1 = downEvent(event1Time, event1Time, 20f, 30f);
         mTouchState.update(event1, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 0, 0);
+        assertSingleTap(mTouchState, 20f, 30f, 0, 0, false);
 
         // Simulate an ACTION_UP event.
         long event2Time = 1001;
         MotionEvent event2 = upEvent(event1Time, event2Time, 20f, 30f);
         mTouchState.update(event2, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f);
+        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f, false);
 
         // Generate an ACTION_DOWN event whose time is after the double-tap timeout.
         long event3Time = event2Time + ViewConfiguration.getDoubleTapTimeout() + 1;
         MotionEvent event3 = downEvent(event3Time, event3Time, 22f, 33f);
         mTouchState.update(event3, mConfig);
-        assertSingleTap(mTouchState, 22f, 33f, 20f, 30f);
+        assertSingleTap(mTouchState, 22f, 33f, 20f, 30f, false);
     }
 
     @Test
@@ -71,19 +74,19 @@
         long event1Time = 1000;
         MotionEvent event1 = downEvent(event1Time, event1Time, 20f, 30f);
         mTouchState.update(event1, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 0, 0);
+        assertSingleTap(mTouchState, 20f, 30f, 0, 0, false);
 
         // Simulate an ACTION_UP event.
         long event2Time = 1001;
         MotionEvent event2 = upEvent(event1Time, event2Time, 20f, 30f);
         mTouchState.update(event2, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f);
+        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f, false);
 
         // Generate an ACTION_DOWN event whose time is within the double-tap timeout.
         long event3Time = 1002;
         MotionEvent event3 = downEvent(event3Time, event3Time, 22f, 33f);
         mTouchState.update(event3, mConfig);
-        assertTap(mTouchState, 22f, 33f, 20f, 30f,
+        assertMultiTap(mTouchState, 22f, 33f, 20f, 30f,
                 MultiTapStatus.DOUBLE_TAP, true);
     }
 
@@ -93,26 +96,26 @@
         long event1Time = 1000;
         MotionEvent event1 = downEvent(event1Time, event1Time, 20f, 30f);
         mTouchState.update(event1, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 0, 0);
+        assertSingleTap(mTouchState, 20f, 30f, 0, 0, false);
 
         // Simulate an ACTION_UP event.
         long event2Time = 1001;
         MotionEvent event2 = upEvent(event1Time, event2Time, 20f, 30f);
         mTouchState.update(event2, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f);
+        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f, false);
 
         // Generate an ACTION_DOWN event whose time is within the double-tap timeout.
         long event3Time = 1002;
         MotionEvent event3 = downEvent(event3Time, event3Time, 200f, 300f);
         mTouchState.update(event3, mConfig);
-        assertTap(mTouchState, 200f, 300f, 20f, 30f,
+        assertMultiTap(mTouchState, 200f, 300f, 20f, 30f,
                 MultiTapStatus.DOUBLE_TAP, false);
 
         // Simulate an ACTION_UP event.
         long event4Time = 1003;
         MotionEvent event4 = upEvent(event3Time, event4Time, 200f, 300f);
         mTouchState.update(event4, mConfig);
-        assertTap(mTouchState, 200f, 300f, 200f, 300f,
+        assertMultiTap(mTouchState, 200f, 300f, 200f, 300f,
                 MultiTapStatus.DOUBLE_TAP, false);
     }
 
@@ -123,21 +126,21 @@
         MotionEvent event1 = downEvent(event1Time, event1Time, 20f, 30f);
         event1.setSource(InputDevice.SOURCE_MOUSE);
         mTouchState.update(event1, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 0, 0);
+        assertSingleTap(mTouchState, 20f, 30f, 0, 0, false);
 
         // Simulate an ACTION_UP event.
         long event2Time = 1001;
         MotionEvent event2 = upEvent(event1Time, event2Time, 20f, 30f);
         event2.setSource(InputDevice.SOURCE_MOUSE);
         mTouchState.update(event2, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f);
+        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f, false);
 
         // Generate a second ACTION_DOWN event whose time is within the double-tap timeout.
         long event3Time = 1002;
         MotionEvent event3 = downEvent(event3Time, event3Time, 21f, 31f);
         event3.setSource(InputDevice.SOURCE_MOUSE);
         mTouchState.update(event3, mConfig);
-        assertTap(mTouchState, 21f, 31f, 20f, 30f,
+        assertMultiTap(mTouchState, 21f, 31f, 20f, 30f,
                 MultiTapStatus.DOUBLE_TAP, true);
 
         // Simulate an ACTION_UP event.
@@ -145,7 +148,7 @@
         MotionEvent event4 = upEvent(event3Time, event4Time, 21f, 31f);
         event4.setSource(InputDevice.SOURCE_MOUSE);
         mTouchState.update(event4, mConfig);
-        assertTap(mTouchState, 21f, 31f, 21f, 31f,
+        assertMultiTap(mTouchState, 21f, 31f, 21f, 31f,
                 MultiTapStatus.DOUBLE_TAP, true);
 
         // Generate a third ACTION_DOWN event whose time is within the double-tap timeout.
@@ -153,7 +156,7 @@
         MotionEvent event5 = downEvent(event5Time, event5Time, 22f, 32f);
         event5.setSource(InputDevice.SOURCE_MOUSE);
         mTouchState.update(event5, mConfig);
-        assertTap(mTouchState, 22f, 32f, 21f, 31f,
+        assertMultiTap(mTouchState, 22f, 32f, 21f, 31f,
                 MultiTapStatus.TRIPLE_CLICK, true);
     }
 
@@ -163,34 +166,71 @@
         long event1Time = 1000;
         MotionEvent event1 = downEvent(event1Time, event1Time, 20f, 30f);
         mTouchState.update(event1, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 0, 0);
+        assertSingleTap(mTouchState, 20f, 30f, 0, 0, false);
 
         // Simulate an ACTION_UP event.
         long event2Time = 1001;
         MotionEvent event2 = upEvent(event1Time, event2Time, 20f, 30f);
         mTouchState.update(event2, mConfig);
-        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f);
+        assertSingleTap(mTouchState, 20f, 30f, 20f, 30f, false);
 
         // Generate a second ACTION_DOWN event whose time is within the double-tap timeout.
         long event3Time = 1002;
         MotionEvent event3 = downEvent(event3Time, event3Time, 21f, 31f);
         mTouchState.update(event3, mConfig);
-        assertTap(mTouchState, 21f, 31f, 20f, 30f,
+        assertMultiTap(mTouchState, 21f, 31f, 20f, 30f,
                 MultiTapStatus.DOUBLE_TAP, true);
 
         // Simulate an ACTION_UP event.
         long event4Time = 1003;
         MotionEvent event4 = upEvent(event3Time, event4Time, 21f, 31f);
         mTouchState.update(event4, mConfig);
-        assertTap(mTouchState, 21f, 31f, 21f, 31f,
+        assertMultiTap(mTouchState, 21f, 31f, 21f, 31f,
                 MultiTapStatus.DOUBLE_TAP, true);
 
         // Generate a third ACTION_DOWN event whose time is within the double-tap timeout.
         long event5Time = 1004;
         MotionEvent event5 = downEvent(event5Time, event5Time, 22f, 32f);
         mTouchState.update(event5, mConfig);
-        assertTap(mTouchState, 22f, 32f, 21f, 31f,
-                MultiTapStatus.FIRST_TAP, false);
+        assertSingleTap(mTouchState, 22f, 32f, 21f, 31f, false);
+    }
+
+    @Test
+    public void testUpdate_drag() throws Exception {
+        // Simulate an ACTION_DOWN event.
+        long event1Time = 1000;
+        MotionEvent event1 = downEvent(event1Time, event1Time, 20f, 30f);
+        mTouchState.update(event1, mConfig);
+        assertSingleTap(mTouchState, 20f, 30f, 0, 0, false);
+
+        // Simulate an ACTION_MOVE event whose location is not far enough to start a drag.
+        long event2Time = 1001;
+        MotionEvent event2 = moveEvent(event1Time, event2Time, 21f, 30f);
+        mTouchState.update(event2, mConfig);
+        assertSingleTap(mTouchState, 20f, 30f, 0, 0, false);
+
+        // Simulate another ACTION_MOVE event whose location is far enough to start a drag.
+        int touchSlop = mConfig.getScaledTouchSlop();
+        float newX = event1.getX() + touchSlop + 1;
+        float newY = event1.getY();
+        long event3Time = 1002;
+        MotionEvent event3 = moveEvent(event3Time, event3Time, newX, newY);
+        mTouchState.update(event3, mConfig);
+        assertSingleTap(mTouchState, 20f, 30f, 0, 0, true);
+
+        // Simulate an ACTION_UP event.
+        long event4Time = 1003;
+        MotionEvent event4 = upEvent(event3Time, event4Time, 200f, 300f);
+        mTouchState.update(event4, mConfig);
+        assertSingleTap(mTouchState, 20f, 30f, 200f, 300f, false);
+    }
+
+    @Test
+    public void testIsDistanceWithin() throws Exception {
+        assertTrue(EditorTouchState.isDistanceWithin(0, 0, 0, 0, 8));
+        assertTrue(EditorTouchState.isDistanceWithin(3, 9, 5, 11, 8));
+        assertTrue(EditorTouchState.isDistanceWithin(5, 11, 3, 9, 8));
+        assertFalse(EditorTouchState.isDistanceWithin(5, 10, 5, 20, 8));
     }
 
     private static MotionEvent downEvent(long downTime, long eventTime, float x, float y) {
@@ -201,8 +241,12 @@
         return MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, x, y, 0);
     }
 
+    private static MotionEvent moveEvent(long downTime, long eventTime, float x, float y) {
+        return MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, x, y, 0);
+    }
+
     private static void assertSingleTap(EditorTouchState touchState, float lastDownX,
-            float lastDownY, float lastUpX, float lastUpY) {
+            float lastDownY, float lastUpX, float lastUpY, boolean isMovedEnoughForDrag) {
         assertThat(touchState.getLastDownX(), is(lastDownX));
         assertThat(touchState.getLastDownY(), is(lastDownY));
         assertThat(touchState.getLastUpX(), is(lastUpX));
@@ -211,9 +255,10 @@
         assertThat(touchState.isTripleClick(), is(false));
         assertThat(touchState.isMultiTap(), is(false));
         assertThat(touchState.isMultiTapInSameArea(), is(false));
+        assertThat(touchState.isMovedEnoughForDrag(), is(isMovedEnoughForDrag));
     }
 
-    private static void assertTap(EditorTouchState touchState,
+    private static void assertMultiTap(EditorTouchState touchState,
             float lastDownX, float lastDownY, float lastUpX, float lastUpY,
             @MultiTapStatus int multiTapStatus, boolean isMultiTapInSameArea) {
         assertThat(touchState.getLastDownX(), is(lastDownX));
@@ -225,5 +270,6 @@
         assertThat(touchState.isMultiTap(), is(multiTapStatus == MultiTapStatus.DOUBLE_TAP
                 || multiTapStatus == MultiTapStatus.TRIPLE_CLICK));
         assertThat(touchState.isMultiTapInSameArea(), is(isMultiTapInSameArea));
+        assertThat(touchState.isMovedEnoughForDrag(), is(false));
     }
 }
diff --git a/core/tests/coretests/src/android/widget/espresso/TextViewActions.java b/core/tests/coretests/src/android/widget/espresso/TextViewActions.java
index 83ce67b..4808a0b 100644
--- a/core/tests/coretests/src/android/widget/espresso/TextViewActions.java
+++ b/core/tests/coretests/src/android/widget/espresso/TextViewActions.java
@@ -358,6 +358,27 @@
     }
 
     /**
+     * Returns an action that drags on text from startIndex to endIndex on the TextView.<br>
+     * <br>
+     * View constraints:
+     * <ul>
+     * <li>must be a TextView displayed on screen
+     * <ul>
+     *
+     * @param startIndex The index of the TextView's text to start a drag from
+     * @param endIndex The index of the TextView's text to end the drag at
+     */
+    public static ViewAction dragOnText(int startIndex, int endIndex) {
+        return actionWithAssertions(
+                new DragAction(
+                        DragAction.Drag.TAP,
+                        new TextCoordinates(startIndex),
+                        new TextCoordinates(endIndex),
+                        Press.FINGER,
+                        TextView.class));
+    }
+
+    /**
      * A provider of the x, y coordinates of the handle dragging point.
      */
     private static final class CurrentHandleCoordinates implements CoordinatesProvider {