[Magnifier - 1] Initial implementation and wiring

* implementation of a magnifier which can be attached to any view
* important APIs:
** show(float centerXOnScreen, float centerYOnScreen, float scale)
** dismiss()
* smart offset => shows below if there is no space above
* controlled by boolean flag (easy to turn off)
* attached the magnifier to Editor's handles
* vertically snaps to the middle of the line containing the
  selection
* horizontally snaps to the offset of the character where
  the selection starts/ends

Bug: 66657373
Test: bit FrameworksCoreTests:android.widget.TextViewActivityTest
Test: bit CtsWidgetTestCases:android.widget.cts.TextViewTest
Test: manual test that shows the magnifier working
Change-Id: I1d4616b8bb1210d869ac47dca137ea9636355250
diff --git a/core/java/android/view/ViewRootImpl.java b/core/java/android/view/ViewRootImpl.java
index d988d66..a346863 100644
--- a/core/java/android/view/ViewRootImpl.java
+++ b/core/java/android/view/ViewRootImpl.java
@@ -366,7 +366,7 @@
 
     // These can be accessed by any thread, must be protected with a lock.
     // Surface can never be reassigned or cleared (use Surface.clear()).
-    final Surface mSurface = new Surface();
+    public final Surface mSurface = new Surface();
 
     boolean mAdded;
     boolean mAddedTouchMode;
diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java
index 0f61724..afa9610 100644
--- a/core/java/android/widget/Editor.java
+++ b/core/java/android/widget/Editor.java
@@ -119,6 +119,7 @@
 import com.android.internal.util.GrowingArrayUtils;
 import com.android.internal.util.Preconditions;
 import com.android.internal.widget.EditableInputConnection;
+import com.android.internal.widget.Magnifier;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -138,6 +139,9 @@
 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.
+    private static final boolean FLAG_USE_MAGNIFIER = false;
 
     static final int BLINK = 500;
     private static final int DRAG_SHADOW_MAX_TEXT_LENGTH = 20;
@@ -161,6 +165,17 @@
     private static final int MENU_ITEM_ORDER_PASTE_AS_PLAIN_TEXT = 11;
     private static final int MENU_ITEM_ORDER_PROCESS_TEXT_INTENT_ACTIONS_START = 100;
 
+    private static final float MAGNIFIER_ZOOM = 1.5f;
+    @IntDef({MagnifierHandleTrigger.SELECTION_START,
+            MagnifierHandleTrigger.SELECTION_END,
+            MagnifierHandleTrigger.INSERTION})
+    @Retention(RetentionPolicy.SOURCE)
+    private @interface MagnifierHandleTrigger {
+        int INSERTION = 0;
+        int SELECTION_START = 1;
+        int SELECTION_END = 2;
+    }
+
     // Each Editor manages its own undo stack.
     private final UndoManager mUndoManager = new UndoManager();
     private UndoOwner mUndoOwner = mUndoManager.getOwner(UNDO_OWNER_TAG, this);
@@ -179,6 +194,8 @@
 
     private final boolean mHapticTextHandleEnabled;
 
+    private final Magnifier mMagnifier;
+
     // Used to highlight a word when it is corrected by the IME
     private CorrectionHighlighter mCorrectionHighlighter;
 
@@ -325,6 +342,8 @@
         mProcessTextIntentActionsHandler = new ProcessTextIntentActionsHandler(this);
         mHapticTextHandleEnabled = mTextView.getContext().getResources().getBoolean(
                 com.android.internal.R.bool.config_enableHapticTextHandle);
+
+        mMagnifier = FLAG_USE_MAGNIFIER ? new Magnifier(mTextView) : null;
     }
 
     ParcelableParcel saveInstanceState() {
@@ -4353,6 +4372,9 @@
 
         protected abstract void updatePosition(float x, float y, boolean fromTouchScreen);
 
+        @MagnifierHandleTrigger
+        protected abstract int getMagnifierHandleTrigger();
+
         protected boolean isAtRtlRun(@NonNull Layout layout, int offset) {
             return layout.isRtlCharAt(offset);
         }
@@ -4490,6 +4512,53 @@
             return 0;
         }
 
+        protected final void showMagnifier() {
+            if (mMagnifier == null) {
+                return;
+            }
+
+            final int trigger = getMagnifierHandleTrigger();
+            final int offset;
+            switch (trigger) {
+                case MagnifierHandleTrigger.INSERTION: // Fall through.
+                case MagnifierHandleTrigger.SELECTION_START:
+                    offset = mTextView.getSelectionStart();
+                    break;
+                case MagnifierHandleTrigger.SELECTION_END:
+                    offset = mTextView.getSelectionEnd();
+                    break;
+                default:
+                    offset = -1;
+                    break;
+            }
+
+            if (offset == -1) {
+                dismissMagnifier();
+            }
+
+            final Layout layout = mTextView.getLayout();
+            final int lineNumber = layout.getLineForOffset(offset);
+            // Horizontally snap to character offset.
+            final float xPosInView = getHorizontal(mTextView.getLayout(), offset);
+            // Vertically snap to middle of current line.
+            final float yPosInView = (mTextView.getLayout().getLineTop(lineNumber)
+                    + mTextView.getLayout().getLineBottom(lineNumber)) / 2.0f;
+            final int[] coordinatesOnScreen = new int[2];
+            mTextView.getLocationOnScreen(coordinatesOnScreen);
+            final float centerXOnScreen = xPosInView + mTextView.getTotalPaddingLeft()
+                    - mTextView.getScrollX() + coordinatesOnScreen[0];
+            final float centerYOnScreen = yPosInView + mTextView.getTotalPaddingTop()
+                    - mTextView.getScrollY() + coordinatesOnScreen[1];
+
+            mMagnifier.show(centerXOnScreen, centerYOnScreen, MAGNIFIER_ZOOM);
+        }
+
+        protected final void dismissMagnifier() {
+            if (mMagnifier != null) {
+                mMagnifier.dismiss();
+            }
+        }
+
         @Override
         public boolean onTouchEvent(MotionEvent ev) {
             updateFloatingToolbarVisibility(ev);
@@ -4542,10 +4611,7 @@
 
                 case MotionEvent.ACTION_UP:
                     filterOnTouchUp(ev.isFromSource(InputDevice.SOURCE_TOUCHSCREEN));
-                    mIsDragging = false;
-                    updateDrawable();
-                    break;
-
+                    // Fall through.
                 case MotionEvent.ACTION_CANCEL:
                     mIsDragging = false;
                     updateDrawable();
@@ -4671,6 +4737,11 @@
                 case MotionEvent.ACTION_DOWN:
                     mDownPositionX = ev.getRawX();
                     mDownPositionY = ev.getRawY();
+                    showMagnifier();
+                    break;
+
+                case MotionEvent.ACTION_MOVE:
+                    showMagnifier();
                     break;
 
                 case MotionEvent.ACTION_UP:
@@ -4696,11 +4767,10 @@
                             mTextActionMode.invalidateContentRect();
                         }
                     }
-                    hideAfterDelay();
-                    break;
-
+                    // Fall through.
                 case MotionEvent.ACTION_CANCEL:
                     hideAfterDelay();
+                    dismissMagnifier();
                     break;
 
                 default:
@@ -4751,6 +4821,12 @@
             super.onDetached();
             removeHiderCallback();
         }
+
+        @Override
+        @MagnifierHandleTrigger
+        protected int getMagnifierHandleTrigger() {
+            return MagnifierHandleTrigger.INSERTION;
+        }
     }
 
     @Retention(RetentionPolicy.SOURCE)
@@ -5009,12 +5085,26 @@
         @Override
         public boolean onTouchEvent(MotionEvent event) {
             boolean superResult = super.onTouchEvent(event);
-            if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
-                // Reset the touch word offset and x value when the user
-                // re-engages the handle.
-                mTouchWordDelta = 0.0f;
-                mPrevX = UNSET_X_VALUE;
+
+            switch (event.getActionMasked()) {
+                case MotionEvent.ACTION_DOWN:
+                    // Reset the touch word offset and x value when the user
+                    // re-engages the handle.
+                    mTouchWordDelta = 0.0f;
+                    mPrevX = UNSET_X_VALUE;
+                    showMagnifier();
+                    break;
+
+                case MotionEvent.ACTION_MOVE:
+                    showMagnifier();
+                    break;
+
+                case MotionEvent.ACTION_UP:
+                case MotionEvent.ACTION_CANCEL:
+                    dismissMagnifier();
+                    break;
             }
+
             return superResult;
         }
 
@@ -5110,6 +5200,13 @@
                 return isRtlChar == isRtlParagraph ? primaryOffset : secondaryOffset;
             }
         }
+
+        @MagnifierHandleTrigger
+        protected int getMagnifierHandleTrigger() {
+            return isStartHandle()
+                    ? MagnifierHandleTrigger.SELECTION_START
+                    : MagnifierHandleTrigger.SELECTION_END;
+        }
     }
 
     private int getCurrentLineAdjustedForSlop(Layout layout, int prevLine, float y) {
diff --git a/core/java/com/android/internal/widget/Magnifier.java b/core/java/com/android/internal/widget/Magnifier.java
new file mode 100644
index 0000000..86e7b38
--- /dev/null
+++ b/core/java/com/android/internal/widget/Magnifier.java
@@ -0,0 +1,184 @@
+/*
+ * 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 com.android.internal.widget;
+
+import android.annotation.FloatRange;
+import android.annotation.NonNull;
+import android.annotation.UiThread;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.os.Handler;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.PixelCopy;
+import android.view.View;
+import android.view.ViewRootImpl;
+import android.widget.ImageView;
+import android.widget.PopupWindow;
+
+import com.android.internal.R;
+import com.android.internal.util.Preconditions;
+
+/**
+ * Android magnifier widget. Can be used by any view which is attached to window.
+ */
+public final class Magnifier {
+    private static final String LOG_TAG = "magnifier";
+    // The view for which this magnifier is attached.
+    private final View mView;
+    // The window containing the magnifier.
+    private final PopupWindow mWindow;
+    // The center coordinates of the window containing the magnifier.
+    private final Point mWindowCoords = new Point();
+    // The width of the window containing the magnifier.
+    private final int mWindowWidth;
+    // The height of the window containing the magnifier.
+    private final int mWindowHeight;
+    // The bitmap used to display the contents of the magnifier.
+    private final Bitmap mBitmap;
+    // The center coordinates of the content that is to be magnified.
+    private final Point mCenterZoomCoords = new Point();
+    // The callback of the pixel copy request will be invoked on this Handler when
+    // the copy is finished.
+    private final Handler mPixelCopyHandler = Handler.getMain();
+
+    /**
+     * Initializes a magnifier.
+     *
+     * @param view the view for which this magnifier is attached
+     */
+    @UiThread
+    public Magnifier(@NonNull View view) {
+        mView = Preconditions.checkNotNull(view);
+        final Context context = mView.getContext();
+        final View content = LayoutInflater.from(context).inflate(R.layout.magnifier, null);
+        mWindowWidth = context.getResources().getDimensionPixelSize(R.dimen.magnifier_width);
+        mWindowHeight = context.getResources().getDimensionPixelSize(R.dimen.magnifier_height);
+        final float elevation = context.getResources().getDimension(R.dimen.magnifier_elevation);
+
+        mWindow = new PopupWindow(context);
+        mWindow.setContentView(content);
+        mWindow.setWidth(mWindowWidth);
+        mWindow.setHeight(mWindowHeight);
+        mWindow.setElevation(elevation);
+        mWindow.setTouchable(false);
+        mWindow.setBackgroundDrawable(null);
+
+        mBitmap = Bitmap.createBitmap(mWindowWidth, mWindowHeight, Bitmap.Config.ARGB_8888);
+        getImageView().setImageBitmap(mBitmap);
+    }
+
+    /**
+     * Shows the magnifier on the screen.
+     *
+     * @param centerXOnScreen horizontal coordinate of the center point of the magnifier source
+     * @param centerYOnScreen vertical coordinate of the center point of the magnifier source
+     * @param scale the scale at which the magnifier zooms on the source content
+     */
+    public void show(@FloatRange(from=0) float centerXOnScreen,
+            @FloatRange(from=0) float centerYOnScreen,
+            @FloatRange(from=1, to=10) float scale) {
+        maybeResizeBitmap(scale);
+        configureCoordinates(centerXOnScreen, centerYOnScreen);
+        performPixelCopy();
+
+        if (mWindow.isShowing()) {
+            mWindow.update(mWindowCoords.x, mWindowCoords.y, mWindow.getWidth(),
+                    mWindow.getHeight());
+        } else {
+            mWindow.showAtLocation(mView.getRootView(), Gravity.NO_GRAVITY,
+                    mWindowCoords.x, mWindowCoords.y);
+        }
+    }
+
+    /**
+     * Dismisses the magnifier from the screen.
+     */
+    public void dismiss() {
+        mWindow.dismiss();
+    }
+
+    /**
+     * @return the height of the magnifier window.
+     */
+    public int getHeight() {
+        return mWindowHeight;
+    }
+
+    /**
+     * @return the width of the magnifier window.
+     */
+    public int getWidth() {
+        return mWindowWidth;
+    }
+
+    private void maybeResizeBitmap(float scale) {
+        final int bitmapWidth = (int) (mWindowWidth / scale);
+        final int bitmapHeight = (int) (mWindowHeight / scale);
+        if (mBitmap.getWidth() != bitmapWidth || mBitmap.getHeight() != bitmapHeight) {
+            mBitmap.reconfigure(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888);
+            getImageView().setImageBitmap(mBitmap);
+        }
+    }
+
+    private void configureCoordinates(float posXOnScreen, float posYOnScreen) {
+        mCenterZoomCoords.x = (int) posXOnScreen;
+        mCenterZoomCoords.y = (int) posYOnScreen;
+
+        final int verticalMagnifierOffset = mView.getContext().getResources().getDimensionPixelSize(
+                R.dimen.magnifier_offset);
+        final int availableTopSpace = (mCenterZoomCoords.y - mWindowHeight / 2)
+                - verticalMagnifierOffset - (mBitmap.getHeight() / 2);
+
+        mWindowCoords.x = mCenterZoomCoords.x - mWindowWidth / 2;
+        mWindowCoords.y = mCenterZoomCoords.y - mWindowHeight / 2
+                + verticalMagnifierOffset * (availableTopSpace > 0 ? -1 : 1);
+    }
+
+    private void performPixelCopy() {
+        int startX = mCenterZoomCoords.x - mBitmap.getWidth() / 2;
+        // Clamp startX value to avoid distorting the rendering of the magnifier content.
+        if (startX < 0) {
+            startX = 0;
+        } else if (startX + mBitmap.getWidth() > mView.getWidth()) {
+            startX = mView.getWidth() - mBitmap.getWidth();
+        }
+
+        final int startY = mCenterZoomCoords.y - mBitmap.getHeight() / 2;
+        final ViewRootImpl viewRootImpl = mView.getViewRootImpl();
+
+        if (viewRootImpl != null && viewRootImpl.mSurface != null
+                && viewRootImpl.mSurface.isValid()) {
+            PixelCopy.request(
+                    viewRootImpl.mSurface,
+                    new Rect(startX, startY, startX + mBitmap.getWidth(),
+                            startY + mBitmap.getHeight()),
+                    mBitmap,
+                    result -> getImageView().invalidate(),
+                    mPixelCopyHandler);
+        } else {
+            Log.d(LOG_TAG, "Could not perform PixelCopy request");
+        }
+    }
+
+    private ImageView getImageView() {
+        return mWindow.getContentView().findViewById(R.id.magnifier_image);
+    }
+}
diff --git a/core/res/res/layout/magnifier.xml b/core/res/res/layout/magnifier.xml
new file mode 100644
index 0000000..181e5e5
--- /dev/null
+++ b/core/res/res/layout/magnifier.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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
+  -->
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:background="?android:attr/floatingToolbarPopupBackgroundDrawable">
+    <ImageView
+        android:id="@+id/magnifier_image"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+</LinearLayout>
diff --git a/core/res/res/values/dimens.xml b/core/res/res/values/dimens.xml
index 16c8578..b3d0053 100644
--- a/core/res/res/values/dimens.xml
+++ b/core/res/res/values/dimens.xml
@@ -520,6 +520,12 @@
     <dimen name="floating_toolbar_vertical_margin">8dp</dimen>
     <dimen name="content_rect_bottom_clip_allowance">20dp</dimen>
 
+    <!-- Magnifier dimensions -->
+    <dimen name="magnifier_width">200dp</dimen>
+    <dimen name="magnifier_height">48dp</dimen>
+    <dimen name="magnifier_elevation">2dp</dimen>
+    <dimen name="magnifier_offset">42dp</dimen>
+
     <dimen name="chooser_grid_padding">0dp</dimen>
     <!-- Spacing around the background change frome service to non-service -->
     <dimen name="chooser_service_spacing">8dp</dimen>
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 0732f0d..b32daee9 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -2474,6 +2474,14 @@
   <java-symbol type="drawable" name="ft_avd_tooverflow_animation" />
   <java-symbol type="attr" name="floatingToolbarDividerColor" />
 
+  <!-- Magnifier -->
+  <java-symbol type="id" name="magnifier_image" />
+  <java-symbol type="layout" name="magnifier" />
+  <java-symbol type="dimen" name="magnifier_width" />
+  <java-symbol type="dimen" name="magnifier_height" />
+  <java-symbol type="dimen" name="magnifier_elevation" />
+  <java-symbol type="dimen" name="magnifier_offset" />
+
   <java-symbol type="string" name="date_picker_prev_month_button" />
   <java-symbol type="string" name="date_picker_next_month_button" />
   <java-symbol type="layout" name="date_picker_month_item_material" />