Expand the animation from the user's last touch point

The Smart Select animation now expands from the spot the user last
lifted their finger.

In order to achieve this, the last up event coordinates need to be
tracked in Editor.

Since it's possible to trigger Smart Select by having the second of the
two taps outside any of the rectangles, the touch point gets moved into
the nearest rectangle and the animation starts from that point.

Test: manual - try out Smart Select by touching different words at
different points
Test: manual - try to trigger Smart Select with a double tap where the
second tap is outside of the word
Test: bit FrameworksCoreTests:android.widget.SelectionActionModeHelperTest
Test: bit CtsViewTestCases:android.view.textclassifier.cts.TextClassificationManagerTest
Test: bit FrameworksCoreTests:android.widget.TextViewActivityTest
Test: bit CtsAccessibilityServiceTestCases:android.accessibilityservice.cts.AccessibilityTextTraversalTest
Change-Id: I96844e8307554b010b476673820f98dae09c0cc3
diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java
index d02d6ff..7fa8066 100644
--- a/core/java/android/widget/Editor.java
+++ b/core/java/android/widget/Editor.java
@@ -260,6 +260,7 @@
     private PositionListener mPositionListener;
 
     private float mLastDownPositionX, mLastDownPositionY;
+    private float mLastUpPositionX, mLastUpPositionY;
     private float mContextMenuAnchorX, mContextMenuAnchorY;
     Callback mCustomSelectionActionModeCallback;
     Callback mCustomInsertionActionModeCallback;
@@ -1130,6 +1131,14 @@
         return handled;
     }
 
+    float getLastUpPositionX() {
+        return mLastUpPositionX;
+    }
+
+    float getLastUpPositionY() {
+        return mLastUpPositionY;
+    }
+
     private long getLastTouchOffsets() {
         SelectionModifierCursorController selectionController = getSelectionController();
         final int minOffset = selectionController.getMinTouchOffset();
@@ -1371,6 +1380,11 @@
             mShowSuggestionRunnable = null;
         }
 
+        if (event.getActionMasked() == MotionEvent.ACTION_UP) {
+            mLastUpPositionX = event.getX();
+            mLastUpPositionY = event.getY();
+        }
+
         if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
             mLastDownPositionX = event.getX();
             mLastDownPositionY = event.getY();
diff --git a/core/java/android/widget/SelectionActionModeHelper.java b/core/java/android/widget/SelectionActionModeHelper.java
index 5e70ef0..2561ffe 100644
--- a/core/java/android/widget/SelectionActionModeHelper.java
+++ b/core/java/android/widget/SelectionActionModeHelper.java
@@ -20,6 +20,7 @@
 import android.annotation.Nullable;
 import android.annotation.UiThread;
 import android.annotation.WorkerThread;
+import android.graphics.PointF;
 import android.graphics.RectF;
 import android.os.AsyncTask;
 import android.os.LocaleList;
@@ -27,13 +28,13 @@
 import android.text.Selection;
 import android.text.Spannable;
 import android.text.TextUtils;
-import android.util.Pair;
 import android.view.ActionMode;
 import android.view.textclassifier.TextClassification;
 import android.view.textclassifier.TextClassifier;
 import android.view.textclassifier.TextSelection;
 import android.widget.Editor.SelectionModifierCursorController;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.Preconditions;
 
 import java.util.ArrayList;
@@ -45,8 +46,10 @@
 /**
  * Helper class for starting selection action mode
  * (synchronously without the TextClassifier, asynchronously with the TextClassifier).
+ * @hide
  */
 @UiThread
+@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
 final class SelectionActionModeHelper {
 
     /**
@@ -224,15 +227,15 @@
             rectangle.bottom += textView.getPaddingTop();
         }
 
-        final RectF firstRectangle = selectionRectangles.get(0);
+        final PointF touchPoint = new PointF(
+                mEditor.getLastUpPositionX(),
+                mEditor.getLastUpPositionY());
 
-        // TODO use the original touch point instead of the hardcoded point generated here
-        final Pair<Float, Float> halfPoint = new Pair<>(
-                firstRectangle.centerX(),
-                firstRectangle.centerY());
+        final PointF animationStartPoint =
+                movePointInsideNearestRectangle(touchPoint, selectionRectangles);
 
         mSmartSelectSprite.startAnimation(
-                halfPoint,
+                animationStartPoint,
                 selectionRectangles,
                 onAnimationEndCallback);
     }
@@ -248,6 +251,39 @@
         return result;
     }
 
+    /** @hide */
+    @VisibleForTesting
+    public static PointF movePointInsideNearestRectangle(final PointF point,
+            final List<RectF> rectangles) {
+        float bestX = -1;
+        float bestY = -1;
+        double bestDistance = Double.MAX_VALUE;
+
+        for (final RectF rectangle : rectangles) {
+            final float candidateY = rectangle.centerY();
+            final float candidateX;
+
+            if (point.x > rectangle.right) {
+                candidateX = rectangle.right;
+            } else if (point.x < rectangle.left) {
+                candidateX = rectangle.left;
+            } else {
+                candidateX = point.x;
+            }
+
+            final double candidateDistance = Math.pow(point.x - candidateX, 2)
+                    + Math.pow(point.y - candidateY, 2);
+
+            if (candidateDistance < bestDistance) {
+                bestX = candidateX;
+                bestY = candidateY;
+                bestDistance = candidateDistance;
+            }
+        }
+
+        return new PointF(bestX, bestY);
+    }
+
     private void invalidateActionMode(@Nullable SelectionResult result) {
         cancelSmartSelectAnimation();
         mTextClassification = result != null ? result.mClassification : null;
diff --git a/core/java/android/widget/SmartSelectSprite.java b/core/java/android/widget/SmartSelectSprite.java
index e641a9b..94109d7 100644
--- a/core/java/android/widget/SmartSelectSprite.java
+++ b/core/java/android/widget/SmartSelectSprite.java
@@ -30,11 +30,11 @@
 import android.graphics.Canvas;
 import android.graphics.Paint;
 import android.graphics.Path;
+import android.graphics.PointF;
 import android.graphics.RectF;
 import android.graphics.drawable.Drawable;
 import android.graphics.drawable.ShapeDrawable;
 import android.graphics.drawable.shapes.Shape;
-import android.util.Pair;
 import android.util.TypedValue;
 import android.view.View;
 import android.view.ViewOverlay;
@@ -82,16 +82,16 @@
 
         private final float[] mLineCoordinates;
 
-        private PolygonShape(final List<Pair<Float, Float>> points) {
+        private PolygonShape(final List<PointF> points) {
             mLineCoordinates = new float[points.size() * POINTS_PER_LINE];
 
             int index = 0;
-            Pair<Float, Float> currentPoint = points.get(0);
-            for (final Pair<Float, Float> nextPoint : points) {
-                mLineCoordinates[index] = currentPoint.first;
-                mLineCoordinates[index + 1] = currentPoint.second;
-                mLineCoordinates[index + 2] = nextPoint.first;
-                mLineCoordinates[index + 3] = nextPoint.second;
+            PointF currentPoint = points.get(0);
+            for (final PointF nextPoint : points) {
+                mLineCoordinates[index] = currentPoint.x;
+                mLineCoordinates[index + 1] = currentPoint.y;
+                mLineCoordinates[index + 2] = nextPoint.x;
+                mLineCoordinates[index + 3] = nextPoint.y;
 
                 index += POINTS_PER_LINE;
                 currentPoint = nextPoint;
@@ -342,9 +342,9 @@
             final List<RectF> rectangles,
             final int color) {
         final List<Drawable> drawables = new LinkedList<>();
-        final Set<List<Pair<Float, Float>>> mergedPaths = calculateMergedPolygonPoints(rectangles);
+        final Set<List<PointF>> mergedPaths = calculateMergedPolygonPoints(rectangles);
 
-        for (List<Pair<Float, Float>> path : mergedPaths) {
+        for (List<PointF> path : mergedPaths) {
             // Add the starting point to the end of the polygon so that it ends up closed.
             path.add(path.get(0));
 
@@ -361,7 +361,7 @@
         return drawables;
     }
 
-    private static Set<List<Pair<Float, Float>>> calculateMergedPolygonPoints(
+    private static Set<List<PointF>> calculateMergedPolygonPoints(
             List<RectF> rectangles) {
         final Set<List<RectF>> partitions = new HashSet<>();
         final LinkedList<RectF> listOfRects = new LinkedList<>(rectangles);
@@ -389,20 +389,20 @@
             partitions.add(partition);
         }
 
-        final Set<List<Pair<Float, Float>>> result = new HashSet<>();
+        final Set<List<PointF>> result = new HashSet<>();
         for (List<RectF> partition : partitions) {
-            final List<Pair<Float, Float>> points = new LinkedList<>();
+            final List<PointF> points = new LinkedList<>();
 
             final Stack<RectF> rects = new Stack<>();
             for (RectF rect : partition) {
-                points.add(new Pair<>(rect.right, rect.top));
-                points.add(new Pair<>(rect.right, rect.bottom));
+                points.add(new PointF(rect.right, rect.top));
+                points.add(new PointF(rect.right, rect.bottom));
                 rects.add(rect);
             }
             while (!rects.isEmpty()) {
                 final RectF rect = rects.pop();
-                points.add(new Pair<>(rect.left, rect.bottom));
-                points.add(new Pair<>(rect.left, rect.top));
+                points.add(new PointF(rect.left, rect.bottom));
+                points.add(new PointF(rect.left, rect.top));
             }
 
             result.add(points);
@@ -426,7 +426,7 @@
      * @see #cancelAnimation()
      */
     public void startAnimation(
-            final Pair<Float, Float> start,
+            final PointF start,
             final List<RectF> destinationRectangles,
             final Runnable onAnimationEnd) throws IllegalArgumentException {
         cancelAnimation();
@@ -439,7 +439,7 @@
 
         final RectF centerRectangle = destinationRectangles
                 .stream()
-                .filter((r) -> r.contains(start.first, start.second))
+                .filter((r) -> contains(r, start))
                 .findFirst()
                 .orElseThrow(() -> new IllegalArgumentException(
                         "Center point is not inside any of the rectangles!"));
@@ -452,7 +452,7 @@
             startingOffset += rectangle.width();
         }
 
-        startingOffset += start.first - centerRectangle.left;
+        startingOffset += start.x - centerRectangle.left;
 
         final float centerRectangleHalfHeight = centerRectangle.height() / 2;
         final float startingOffsetLeft = startingOffset - centerRectangleHalfHeight;
@@ -632,6 +632,21 @@
         return result;
     }
 
+    /**
+     * A variant of {@link RectF#contains(float, float)} that also allows the point to reside on
+     * the right boundary of the rectangle.
+     *
+     * @param rectangle the rectangle inside which the point should be to be considered "contained"
+     * @param point     the point which will be tested
+     * @return whether the point is inside the rectangle (or on it's right boundary)
+     */
+    private static boolean contains(final RectF rectangle, final PointF point) {
+        final float x = point.x;
+        final float y = point.y;
+        return x >= rectangle.left && x <= rectangle.right && y >= rectangle.top
+                && y <= rectangle.bottom;
+    }
+
     private void addToOverlay(final Drawable drawable) {
         mView.getOverlay().add(drawable);
         mExistingAnimationDrawables.add(drawable);
diff --git a/core/tests/coretests/src/android/widget/SelectionActionModeHelperTest.java b/core/tests/coretests/src/android/widget/SelectionActionModeHelperTest.java
new file mode 100644
index 0000000..d94a017
--- /dev/null
+++ b/core/tests/coretests/src/android/widget/SelectionActionModeHelperTest.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2017 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 org.junit.Assert.assertEquals;
+
+import android.graphics.PointF;
+import android.graphics.RectF;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Arrays;
+import java.util.List;
+
+@RunWith(JUnit4.class)
+public final class SelectionActionModeHelperTest {
+
+    /*
+     * The test rectangle set is composed of three 1x1 rectangles as illustrated below.
+     *
+     * (0, 0) ____________ (100001, 0)
+     *        |█        █|
+     *        |_█________|
+     * (0, 2)              (100001, 2)
+     */
+    private final List<RectF> mRectFList = Arrays.asList(
+            new RectF(0, 0, 1, 1),
+            new RectF(100000, 0, 100001, 1),
+            new RectF(1, 1, 2, 2));
+
+    @Test
+    public void testMovePointInsideNearestRectangle_pointIsInsideRectangle() {
+        testMovePointInsideNearestRectangle(
+                0.1f /* pointX */,
+                0.1f /* pointY */,
+                0.1f /* expectedPointX */,
+                0.5f /* expectedPointY */);
+    }
+
+    @Test
+    public void testMovePointInsideNearestRectangle_pointIsAboveRectangle() {
+        testMovePointInsideNearestRectangle(
+                0.1f /* pointX */,
+                -1.0f /* pointY */,
+                0.1f /* expectedPointX */,
+                0.5f /* expectedPointY */);
+    }
+
+    @Test
+    public void testMovePointInsideNearestRectangle_pointIsLeftOfRectangle() {
+        testMovePointInsideNearestRectangle(
+                -1.0f /* pointX */,
+                0.4f /* pointY */,
+                0.0f /* expectedPointX */,
+                0.5f /* expectedPointY */);
+    }
+
+    @Test
+    public void testMovePointInsideNearestRectangle_pointIsRightOfRectangle() {
+        testMovePointInsideNearestRectangle(
+                1.1f /* pointX */,
+                0.0f /* pointY */,
+                1.0f /* expectedPointX */,
+                0.5f /* expectedPointY */);
+    }
+
+    @Test
+    public void testMovePointInsideNearestRectangle_pointIsBelowRectangle() {
+        testMovePointInsideNearestRectangle(
+                0.1f /* pointX */,
+                1.1f /* pointY */,
+                0.1f /* expectedPointX */,
+                0.5f /* expectedPointY */);
+    }
+
+    @Test
+    public void testMovePointInsideNearestRectangle_pointIsToRightOfTheRightmostRectangle() {
+        testMovePointInsideNearestRectangle(
+                200000.0f /* pointX */,
+                0.1f /* pointY */,
+                100001.0f /* expectedPointX */,
+                0.5f /* expectedPointY */);
+    }
+
+    private void testMovePointInsideNearestRectangle(final float pointX, final float pointY,
+            final float expectedPointX,
+            final float expectedPointY) {
+        final PointF point = new PointF(pointX, pointY);
+        final PointF adjustedPoint =
+                SelectionActionModeHelper.movePointInsideNearestRectangle(point,
+                        mRectFList);
+
+        assertEquals(expectedPointX, adjustedPoint.x, 0.0f);
+        assertEquals(expectedPointY, adjustedPoint.y, 0.0f);
+    }
+
+}