/* * Copyright (C) 2021 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.view; import static com.android.text.flags.Flags.handwritingCursorPosition; import static com.android.text.flags.Flags.handwritingUnsupportedMessage; import android.annotation.FlaggedApi; import android.annotation.NonNull; import android.annotation.Nullable; import android.content.Context; import android.graphics.Matrix; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Region; import android.text.TextUtils; import android.view.inputmethod.ConnectionlessHandwritingCallback; import android.view.inputmethod.CursorAnchorInfo; import android.view.inputmethod.Flags; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.Editor; import android.widget.TextView; import android.widget.Toast; import com.android.internal.R; import com.android.internal.annotations.VisibleForTesting; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.function.Consumer; /** * Initiates handwriting mode once it detects stylus movement in handwritable areas. * * It is designed to be used by {@link ViewRootImpl}. For every stylus related MotionEvent that is * dispatched to view tree, ViewRootImpl should call {@link #onTouchEvent} method of this class. * And it will automatically request to enter the handwriting mode when the conditions meet. * * Notice that ViewRootImpl should still dispatch MotionEvents to view tree as usual. * And if it successfully enters the handwriting mode, the ongoing MotionEvent stream will be * routed to the input method. Input system will fabricate an ACTION_CANCEL and send to * ViewRootImpl. * * This class does nothing if: * a) MotionEvents are not from stylus. * b) The user taps or long-clicks with a stylus etc. * c) Stylus pointer down position is not within a handwritable area. * * Used by InputMethodManager. * @hide */ public class HandwritingInitiator { /** * The maximum amount of distance a stylus touch can wander before it is considered * handwriting. */ private final int mHandwritingSlop; /** * The timeout used to distinguish tap or long click from handwriting. If the stylus doesn't * move before this timeout, it's not considered as handwriting. */ private final long mHandwritingTimeoutInMillis; private State mState; private final HandwritingAreaTracker mHandwritingAreasTracker = new HandwritingAreaTracker(); /** The reference to the View that currently has the input connection. */ @Nullable @VisibleForTesting public WeakReference mConnectedView = null; /** * When InputConnection restarts for a View, View#onInputConnectionCreatedInternal * might be called before View#onInputConnectionClosedInternal, so we need to count the input * connections and only set mConnectedView to null when mConnectionCount is zero. */ private int mConnectionCount = 0; /** * The reference to the View that currently has focus. * This replaces mConnecteView when {@code Flags#intitiationWithoutInputConnection()} is * enabled. */ @Nullable @VisibleForTesting public WeakReference mFocusedView = null; private final InputMethodManager mImm; private final int[] mTempLocation = new int[2]; private final Rect mTempRect = new Rect(); private final RectF mTempRectF = new RectF(); private final Region mTempRegion = new Region(); private final Matrix mTempMatrix = new Matrix(); /** * The handwrite-able View that is currently the target of a hovering stylus pointer. This is * used to help determine whether the handwriting PointerIcon should be shown in * {@link #onResolvePointerIcon(Context, MotionEvent)} so that we can reduce the number of calls * to {@link #findBestCandidateView(float, float, boolean)}. */ @Nullable private WeakReference mCachedHoverTarget = null; /** * Whether to show the hover icon for the current connected view. * Hover icon should be hidden for the current connected view after handwriting is initiated * for it until one of the following events happens: * a) user performs a click or long click. In other words, if it receives a series of motion * events that don't trigger handwriting, show hover icon again. * b) the stylus hovers on another editor that supports handwriting (or a handwriting delegate). * c) the current connected editor lost focus. * * If the stylus is hovering on an unconnected editor that supports handwriting, we always show * the hover icon. * TODO(b/308827131): Rename to FocusedView after Flag is flipped. */ private boolean mShowHoverIconForConnectedView = true; /** When flag is enabled, touched editors don't wait for InputConnection for initiation. * However, delegation still waits for InputConnection. */ private final boolean mInitiateWithoutConnection = Flags.initiationWithoutInputConnection(); @VisibleForTesting public HandwritingInitiator(@NonNull ViewConfiguration viewConfiguration, @NonNull InputMethodManager inputMethodManager) { mHandwritingSlop = viewConfiguration.getScaledHandwritingSlop(); mHandwritingTimeoutInMillis = ViewConfiguration.getLongPressTimeout(); mImm = inputMethodManager; } /** * Notify the HandwritingInitiator that a new MotionEvent has arrived. * *

The return value indicates whether the event has been fully handled by the * HandwritingInitiator and should not be dispatched to the view tree. This will be true for * ACTION_MOVE events from a stylus gesture after handwriting mode has been initiated, in order * to suppress other actions such as scrolling. * *

If HandwritingInitiator triggers the handwriting mode, a fabricated ACTION_CANCEL event * will be sent to the ViewRootImpl. * * @param motionEvent the stylus {@link MotionEvent} * @return true if the event has been fully handled by the {@link HandwritingInitiator} and * should not be dispatched to the {@link View} tree, or false if the event should be dispatched * to the {@link View} tree as usual */ @VisibleForTesting public boolean onTouchEvent(@NonNull MotionEvent motionEvent) { final int maskedAction = motionEvent.getActionMasked(); switch (maskedAction) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_POINTER_DOWN: mState = null; if (!motionEvent.isStylusPointer()) { // The motion event is not from a stylus event, ignore it. return false; } mState = new State(motionEvent); break; case MotionEvent.ACTION_POINTER_UP: final int pointerId = motionEvent.getPointerId(motionEvent.getActionIndex()); if (mState == null || pointerId != mState.mStylusPointerId) { // ACTION_POINTER_UP is from another stylus pointer, ignore the event. return false; } // Deliberately fall through. case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: // If it's ACTION_CANCEL or ACTION_UP, all the pointers go up. There is no need to // check whether the stylus we are tracking goes up. if (mState != null) { mState.mShouldInitHandwriting = false; if (!mState.mHandled) { // The user just did a click, long click or another stylus gesture, // show hover icon again for the connected view. mShowHoverIconForConnectedView = true; } } return false; case MotionEvent.ACTION_MOVE: if (mState == null) { return false; } // Either we've already tried to initiate handwriting, or the ongoing MotionEvent // sequence is considered to be tap, long-click or other gestures. if (!mState.mShouldInitHandwriting || mState.mExceedHandwritingSlop) { return mState.mHandled; } final long timeElapsed = motionEvent.getEventTime() - mState.mStylusDownTimeInMillis; if (timeElapsed > mHandwritingTimeoutInMillis) { mState.mShouldInitHandwriting = false; return mState.mHandled; } final int pointerIndex = motionEvent.findPointerIndex(mState.mStylusPointerId); final float x = motionEvent.getX(pointerIndex); final float y = motionEvent.getY(pointerIndex); if (largerThanTouchSlop(x, y, mState.mStylusDownX, mState.mStylusDownY)) { mState.mExceedHandwritingSlop = true; View candidateView = findBestCandidateView(mState.mStylusDownX, mState.mStylusDownY, /* isHover */ false); if (candidateView != null && candidateView.isEnabled()) { boolean candidateHasFocus = candidateView.hasFocus(); if (shouldShowHandwritingUnavailableMessageForView(candidateView)) { int messagesResId = (candidateView instanceof TextView tv && tv.isAnyPasswordInputType()) ? R.string.error_handwriting_unsupported_password : R.string.error_handwriting_unsupported; Toast.makeText(candidateView.getContext(), messagesResId, Toast.LENGTH_SHORT).show(); if (!candidateView.hasFocus()) { requestFocusWithoutReveal(candidateView); } mImm.showSoftInput(candidateView, 0); mState.mHandled = true; mState.mShouldInitHandwriting = false; motionEvent.setAction((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) | MotionEvent.ACTION_CANCEL); candidateView.getRootView().dispatchTouchEvent(motionEvent); } else if (candidateView == getConnectedOrFocusedView()) { if (!candidateHasFocus) { requestFocusWithoutReveal(candidateView); } startHandwriting(candidateView); } else if (candidateView.getHandwritingDelegatorCallback() != null) { prepareDelegation(candidateView); } else { if (mInitiateWithoutConnection) { if (!candidateHasFocus) { // schedule for view focus. mState.mPendingFocusedView = new WeakReference<>(candidateView); requestFocusWithoutReveal(candidateView); } } else { mState.mPendingConnectedView = new WeakReference<>(candidateView); if (!candidateHasFocus) { requestFocusWithoutReveal(candidateView); } } } } } return mState.mHandled; } return false; } @Nullable private View getConnectedView() { if (mConnectedView == null) return null; return mConnectedView.get(); } private void clearConnectedView() { mConnectedView = null; mConnectionCount = 0; } /** * Notify HandwritingInitiator that a delegate view (see {@link View#isHandwritingDelegate}) * gained focus. */ public void onDelegateViewFocused(@NonNull View view) { if (mInitiateWithoutConnection) { onEditorFocused(view); } if (view == getConnectedView()) { tryAcceptStylusHandwritingDelegation(view); } } /** * Notify HandwritingInitiator that a new InputConnection is created. * The caller of this method should guarantee that each onInputConnectionCreated call * is paired with a onInputConnectionClosed call. * @param view the view that created the current InputConnection. * @see #onInputConnectionClosed(View) */ public void onInputConnectionCreated(@NonNull View view) { if (mInitiateWithoutConnection && !view.isHandwritingDelegate()) { // When flag is enabled, only delegation continues to wait for InputConnection. return; } if (!view.isAutoHandwritingEnabled()) { clearConnectedView(); return; } final View connectedView = getConnectedView(); if (connectedView == view) { ++mConnectionCount; } else { mConnectedView = new WeakReference<>(view); mConnectionCount = 1; // A new view just gain focus. By default, we should show hover icon for it. mShowHoverIconForConnectedView = true; if (view.isHandwritingDelegate() && tryAcceptStylusHandwritingDelegation(view)) { // tryAcceptStylusHandwritingDelegation should set boolean below, however, we // cannot mock IMM to return true for acceptStylusDelegation(). // TODO(b/324670412): we should move any dependent tests to integration and remove // the assignment below. mShowHoverIconForConnectedView = false; return; } if (!mInitiateWithoutConnection && mState != null && mState.mPendingConnectedView != null && mState.mPendingConnectedView.get() == view) { startHandwriting(view); } } } /** * Notify HandwritingInitiator that a new editor is focused. * @param view the view that received focus. */ @VisibleForTesting public void onEditorFocused(@NonNull View view) { if (!mInitiateWithoutConnection) { return; } if (!view.isAutoHandwritingEnabled()) { clearFocusedView(view); return; } final View focusedView = getFocusedView(); if (focusedView == view) { return; } updateFocusedView(view); if (mState != null && mState.mPendingFocusedView != null && mState.mPendingFocusedView.get() == view) { startHandwriting(view); } } /** * Notify HandwritingInitiator that the InputConnection has closed for the given view. * The caller of this method should guarantee that each onInputConnectionClosed call * is paired with a onInputConnectionCreated call. * @param view the view that closed the InputConnection. */ public void onInputConnectionClosed(@NonNull View view) { if (mInitiateWithoutConnection && !view.isHandwritingDelegate()) { return; } final View connectedView = getConnectedView(); if (connectedView == null) return; if (connectedView == view) { --mConnectionCount; if (mConnectionCount == 0) { clearConnectedView(); } } else { // Unexpected branch, set mConnectedView to null to avoid further problem. clearConnectedView(); } } @Nullable private View getFocusedView() { if (mFocusedView == null) return null; return mFocusedView.get(); } /** * Clear the tracked focused view tracked for handwriting initiation. * @param view the focused view. */ public void clearFocusedView(View view) { if (view == null || mFocusedView == null) { return; } if (mFocusedView.get() == view) { mFocusedView = null; } } /** * Called when new {@link Editor} is focused. * @return {@code true} if handwriting can initiate for given view. */ @VisibleForTesting public boolean updateFocusedView(@NonNull View view) { if (!view.shouldInitiateHandwriting()) { mFocusedView = null; return false; } final View focusedView = getFocusedView(); if (focusedView != view) { mFocusedView = new WeakReference<>(view); // A new view just gain focus. By default, we should show hover icon for it. mShowHoverIconForConnectedView = true; } return true; } /** Starts a stylus handwriting session for the view. */ @VisibleForTesting public void startHandwriting(@NonNull View view) { mImm.startStylusHandwriting(view); mState.mHandled = true; mState.mShouldInitHandwriting = false; mShowHoverIconForConnectedView = false; if (view instanceof TextView) { ((TextView) view).hideHint(); } } private void prepareDelegation(View view) { String delegatePackageName = view.getAllowedHandwritingDelegatePackageName(); if (delegatePackageName == null) { delegatePackageName = view.getContext().getOpPackageName(); } if (mImm.isConnectionlessStylusHandwritingAvailable()) { // No other view should have focus during the connectionless handwriting session, as // this could cause user confusion about the input target for the session. view.getViewRootImpl().getView().clearFocus(); mImm.startConnectionlessStylusHandwritingForDelegation( view, getCursorAnchorInfoForConnectionless(view), delegatePackageName, view::post, new DelegationCallback(view, delegatePackageName)); mState.mShouldInitHandwriting = false; } else { mImm.prepareStylusHandwritingDelegation(view, delegatePackageName); view.getHandwritingDelegatorCallback().run(); } mState.mHandled = true; } /** * Starts a stylus handwriting session for the delegate view, if {@link * InputMethodManager#prepareStylusHandwritingDelegation} was previously called. */ @VisibleForTesting public boolean tryAcceptStylusHandwritingDelegation(@NonNull View view) { if (Flags.useZeroJankProxy()) { tryAcceptStylusHandwritingDelegationAsync(view); } else { return tryAcceptStylusHandwritingDelegationInternal(view); } return false; } private boolean tryAcceptStylusHandwritingDelegationInternal(@NonNull View view) { String delegatorPackageName = view.getAllowedHandwritingDelegatorPackageName(); if (delegatorPackageName == null) { delegatorPackageName = view.getContext().getOpPackageName(); } if (mImm.acceptStylusHandwritingDelegation(view, delegatorPackageName)) { onDelegationAccepted(view); return true; } return false; } @FlaggedApi(Flags.FLAG_USE_ZERO_JANK_PROXY) private void tryAcceptStylusHandwritingDelegationAsync(@NonNull View view) { String delegatorPackageName = view.getAllowedHandwritingDelegatorPackageName(); if (delegatorPackageName == null) { delegatorPackageName = view.getContext().getOpPackageName(); } Consumer consumer = delegationAccepted -> { if (delegationAccepted) { onDelegationAccepted(view); } }; mImm.acceptStylusHandwritingDelegation(view, delegatorPackageName, view::post, consumer); } private void onDelegationAccepted(View view) { if (mState != null) { mState.mHandled = true; mState.mShouldInitHandwriting = false; } if (view instanceof TextView) { ((TextView) view).hideHint(); } // A handwriting delegate view is accepted and handwriting starts; hide the // hover icon. mShowHoverIconForConnectedView = false; } /** * Notify that the handwriting area for the given view might be updated. * @param view the view whose handwriting area might be updated. */ public void updateHandwritingAreasForView(@NonNull View view) { mHandwritingAreasTracker.updateHandwritingAreaForView(view); } private static boolean shouldTriggerStylusHandwritingForView(@NonNull View view) { if (!view.shouldInitiateHandwriting()) { return false; } // The view may be a handwriting initiation delegator, in which case it is not the editor // view for which handwriting would be started. However, in almost all cases, the return // values of View#isStylusHandwritingAvailable will be the same for the delegator view and // the delegate editor view. So the delegator view can be used to decide whether handwriting // should be triggered. return view.isStylusHandwritingAvailable(); } private static boolean shouldShowHandwritingUnavailableMessageForView(@NonNull View view) { return (view instanceof TextView) && !shouldTriggerStylusHandwritingForView(view); } private static boolean shouldTriggerHandwritingOrShowUnavailableMessageForView( @NonNull View view) { return (view instanceof TextView) || shouldTriggerStylusHandwritingForView(view); } /** * Returns the pointer icon for the motion event, or null if it doesn't specify the icon. * This gives HandwritingInitiator a chance to show the stylus handwriting icon over a * handwrite-able area. */ public PointerIcon onResolvePointerIcon(Context context, MotionEvent event) { final View hoverView = findHoverView(event); if (hoverView == null || !shouldTriggerStylusHandwritingForView(hoverView)) { return null; } if (mShowHoverIconForConnectedView) { return PointerIcon.getSystemIcon(context, PointerIcon.TYPE_HANDWRITING); } if (hoverView != getConnectedOrFocusedView()) { // The stylus is hovering on another view that supports handwriting. We should show // hover icon. Also reset the mShowHoverIconForFocusedView so that hover // icon is displayed again next time when the stylus hovers on focused view. mShowHoverIconForConnectedView = true; return PointerIcon.getSystemIcon(context, PointerIcon.TYPE_HANDWRITING); } return null; } // TODO(b/308827131): Remove once Flag is flipped. private View getConnectedOrFocusedView() { if (mInitiateWithoutConnection) { return mFocusedView == null ? null : mFocusedView.get(); } else { return mConnectedView == null ? null : mConnectedView.get(); } } private View getCachedHoverTarget() { if (mCachedHoverTarget == null) { return null; } return mCachedHoverTarget.get(); } private View findHoverView(MotionEvent event) { if (!event.isStylusPointer() || !event.isHoverEvent()) { return null; } if (event.getActionMasked() == MotionEvent.ACTION_HOVER_ENTER || event.getActionMasked() == MotionEvent.ACTION_HOVER_MOVE) { final float hoverX = event.getX(event.getActionIndex()); final float hoverY = event.getY(event.getActionIndex()); final View cachedHoverTarget = getCachedHoverTarget(); if (cachedHoverTarget != null) { final Rect handwritingArea = mTempRect; if (getViewHandwritingArea(cachedHoverTarget, handwritingArea) && isInHandwritingArea(handwritingArea, hoverX, hoverY, cachedHoverTarget, /* isHover */ true) && shouldTriggerStylusHandwritingForView(cachedHoverTarget)) { return cachedHoverTarget; } } final View candidateView = findBestCandidateView(hoverX, hoverY, /* isHover */ true); if (candidateView != null) { if (!handwritingUnsupportedMessage()) { mCachedHoverTarget = new WeakReference<>(candidateView); } return candidateView; } } mCachedHoverTarget = null; return null; } private void requestFocusWithoutReveal(View view) { if (!handwritingCursorPosition() && view instanceof EditText editText && !mState.mStylusDownWithinEditorBounds) { // If the stylus down point was inside the EditText's bounds, then the EditText will // automatically set its cursor position nearest to the stylus down point when it // gains focus. If the stylus down point was outside the EditText's bounds (within // the extended handwriting bounds), then we must calculate and set the cursor // position manually. view.getLocationInWindow(mTempLocation); int offset = editText.getOffsetForPosition( mState.mStylusDownX - mTempLocation[0], mState.mStylusDownY - mTempLocation[1]); editText.setSelection(offset); } if (view.getRevealOnFocusHint()) { view.setRevealOnFocusHint(false); view.requestFocus(); view.setRevealOnFocusHint(true); } else { view.requestFocus(); } if (handwritingCursorPosition() && view instanceof EditText editText) { // Move the cursor to the end of the paragraph closest to the stylus down point. view.getLocationInWindow(mTempLocation); int line = editText.getLineAtCoordinate(mState.mStylusDownY - mTempLocation[1]); int paragraphEnd = TextUtils.indexOf(editText.getText(), '\n', editText.getLayout().getLineStart(line)); if (paragraphEnd < 0) { paragraphEnd = editText.getText().length(); } editText.setSelection(paragraphEnd); } } /** * Given the location of the stylus event, return the best candidate view to initialize * handwriting mode or show the handwriting unavailable error message. * * @param x the x coordinates of the stylus event, in the coordinates of the window. * @param y the y coordinates of the stylus event, in the coordinates of the window. */ @Nullable private View findBestCandidateView(float x, float y, boolean isHover) { // TODO(b/308827131): Rename to FocusedView after Flag is flipped. // If the connectedView is not null and do not set any handwriting area, it will check // whether the connectedView's boundary contains the initial stylus position. If true, // directly return the connectedView. final View connectedOrFocusedView = getConnectedOrFocusedView(); if (connectedOrFocusedView != null) { Rect handwritingArea = mTempRect; if (getViewHandwritingArea(connectedOrFocusedView, handwritingArea) && isInHandwritingArea(handwritingArea, x, y, connectedOrFocusedView, isHover) && shouldTriggerHandwritingOrShowUnavailableMessageForView( connectedOrFocusedView)) { if (!isHover && mState != null) { mState.mStylusDownWithinEditorBounds = contains(handwritingArea, x, y, 0f, 0f, 0f, 0f); } return connectedOrFocusedView; } } float minDistance = Float.MAX_VALUE; View bestCandidate = null; // Check the registered handwriting areas. final List handwritableViewInfos = mHandwritingAreasTracker.computeViewInfos(); for (HandwritableViewInfo viewInfo : handwritableViewInfos) { final View view = viewInfo.getView(); final Rect handwritingArea = viewInfo.getHandwritingArea(); if (!isInHandwritingArea(handwritingArea, x, y, view, isHover) || !shouldTriggerHandwritingOrShowUnavailableMessageForView(view)) { continue; } final float distance = distance(handwritingArea, x, y); if (distance == 0f) { if (!isHover && mState != null) { mState.mStylusDownWithinEditorBounds = true; } return view; } if (distance < minDistance) { minDistance = distance; bestCandidate = view; } } return bestCandidate; } /** * Return the square of the distance from point (x, y) to the given rect, which is mainly used * for comparison. The distance is defined to be: the shortest distance between (x, y) to any * point on rect. When (x, y) is contained by the rect, return 0f. */ private static float distance(@NonNull Rect rect, float x, float y) { if (contains(rect, x, y, 0f, 0f, 0f, 0f)) { return 0f; } /* The distance between point (x, y) and rect, there are 2 basic cases: * a) The distance is the distance from (x, y) to the closest corner on rect. * o | | * ---+-----+--- * | | * ---+-----+--- * | | * b) The distance is the distance from (x, y) to the closest edge on rect. * | o | * ---+-----+--- * | | * ---+-----+--- * | | * We define xDistance as following(similar for yDistance): * If x is in [left, right) 0, else min(abs(x - left), abs(x - y)) * For case a, sqrt(xDistance^2 + yDistance^2) is the final distance. * For case b, distance should be yDistance, which is also equal to * sqrt(xDistance^2 + yDistance^2) because xDistance is 0. */ final float xDistance; if (x >= rect.left && x < rect.right) { xDistance = 0f; } else if (x < rect.left) { xDistance = rect.left - x; } else { xDistance = x - rect.right; } final float yDistance; if (y >= rect.top && y < rect.bottom) { yDistance = 0f; } else if (y < rect.top) { yDistance = rect.top - y; } else { yDistance = y - rect.bottom; } // We can omit sqrt here because we only need the distance for comparison. return xDistance * xDistance + yDistance * yDistance; } /** * Return the handwriting area of the given view, represented in the window's coordinate. * If the view didn't set any handwriting area, it will return the view's boundary. * *

The handwriting area is clipped to its visible part. * Notice that the returned rectangle is the view's original handwriting area without the * view's handwriting area extends.

* * @param view the {@link View} whose handwriting area we want to compute. * @param rect the {@link Rect} to receive the result. * * @return true if the view's handwriting area is still visible, or false if it's clipped and * fully invisible. This method only consider the clip by given view's parents, but not the case * where a view is covered by its sibling view. */ private static boolean getViewHandwritingArea(@NonNull View view, @NonNull Rect rect) { final ViewParent viewParent = view.getParent(); if (viewParent != null && view.isAttachedToWindow() && view.isAggregatedVisible()) { final Rect localHandwritingArea = view.getHandwritingArea(); if (localHandwritingArea != null) { rect.set(localHandwritingArea); } else { rect.set(0, 0, view.getWidth(), view.getHeight()); } return viewParent.getChildVisibleRect(view, rect, null); } return false; } /** * Return true if the (x, y) is inside by the given {@link Rect} with the View's * handwriting bounds with offsets applied. */ private boolean isInHandwritingArea(@Nullable Rect handwritingArea, float x, float y, View view, boolean isHover) { if (handwritingArea == null) return false; if (!contains(handwritingArea, x, y, view.getHandwritingBoundsOffsetLeft(), view.getHandwritingBoundsOffsetTop(), view.getHandwritingBoundsOffsetRight(), view.getHandwritingBoundsOffsetBottom())) { return false; } // The returned handwritingArea computed by ViewParent#getChildVisibleRect didn't consider // the case where a view is stacking on top of the editor. (e.g. DrawerLayout, popup) // We must check the hit region of the editor again, and avoid the case where another // view on top of the editor is handling MotionEvents. ViewParent parent = view.getParent(); if (parent == null) { return true; } Region region = mTempRegion; mTempRegion.set(0, 0, view.getWidth(), view.getHeight()); Matrix matrix = mTempMatrix; matrix.reset(); if (!parent.getChildLocalHitRegion(view, region, matrix, isHover)) { return false; } // It's not easy to extend the region by the given handwritingBoundsOffset. Instead, we // create a rectangle surrounding the motion event location and check if this rectangle // overlaps with the hit region of the editor. float left = x - view.getHandwritingBoundsOffsetRight(); float top = y - view.getHandwritingBoundsOffsetBottom(); float right = Math.max(x + view.getHandwritingBoundsOffsetLeft(), left + 1); float bottom = Math.max(y + view.getHandwritingBoundsOffsetTop(), top + 1); RectF rectF = mTempRectF; rectF.set(left, top, right, bottom); matrix.mapRect(rectF); return region.op(Math.round(rectF.left), Math.round(rectF.top), Math.round(rectF.right), Math.round(rectF.bottom), Region.Op.INTERSECT); } /** * Return true if the (x, y) is inside by the given {@link Rect} offset by the given * offsetLeft, offsetTop, offsetRight and offsetBottom. */ private static boolean contains(@NonNull Rect rect, float x, float y, float offsetLeft, float offsetTop, float offsetRight, float offsetBottom) { return x >= rect.left - offsetLeft && x < rect.right + offsetRight && y >= rect.top - offsetTop && y < rect.bottom + offsetBottom; } private boolean largerThanTouchSlop(float x1, float y1, float x2, float y2) { float dx = x1 - x2; float dy = y1 - y2; return dx * dx + dy * dy > mHandwritingSlop * mHandwritingSlop; } /** Object that keeps the MotionEvent related states for HandwritingInitiator. */ private static class State { /** * Whether it should initiate handwriting mode for the current MotionEvent sequence. * (A series of MotionEvents from ACTION_DOWN to ACTION_UP) * * The purpose of this boolean value is: * a) We should only request to start handwriting mode ONCE for each MotionEvent sequence. * If we've already requested to enter handwriting mode for the ongoing MotionEvent * sequence, this boolean is set to false. And it won't request to start handwriting again. * * b) If the MotionEvent sequence is considered to be tap, long-click or other gestures. * This boolean will be set to false, and it won't request to start handwriting. */ private boolean mShouldInitHandwriting; /** * Whether the current MotionEvent sequence has been handled by the handwriting initiator, * either by initiating handwriting mode, or by preparing handwriting delegation. */ private boolean mHandled; /** * Whether the current ongoing stylus MotionEvent sequence already exceeds the * handwriting slop. * It's used for the case where the stylus exceeds handwriting slop before the target View * built InputConnection. */ private boolean mExceedHandwritingSlop; /** * Whether the stylus down point of the MotionEvent sequence was within the editor's bounds * (not including the extended handwriting bounds). */ private boolean mStylusDownWithinEditorBounds; /** * A view which has requested focus and is pending input connection creation. When an input * connection is created for the view, a handwriting session should be started for the view. */ private WeakReference mPendingConnectedView = null; /** * A view which has requested focus and is yet to receive it. * When view receives focus, a handwriting session should be started for the view. */ private WeakReference mPendingFocusedView = null; /** The pointer id of the stylus pointer that is being tracked. */ private final int mStylusPointerId; /** The time stamp when the stylus pointer goes down. */ private final long mStylusDownTimeInMillis; /** The initial location where the stylus pointer goes down. */ private final float mStylusDownX; private final float mStylusDownY; private State(MotionEvent motionEvent) { final int actionIndex = motionEvent.getActionIndex(); mStylusPointerId = motionEvent.getPointerId(actionIndex); mStylusDownTimeInMillis = motionEvent.getEventTime(); mStylusDownX = motionEvent.getX(actionIndex); mStylusDownY = motionEvent.getY(actionIndex); mShouldInitHandwriting = true; mHandled = false; mExceedHandwritingSlop = false; } } /** The helper method to check if the given view is still active for handwriting. */ private static boolean isViewActive(@Nullable View view) { return view != null && view.isAttachedToWindow() && view.isAggregatedVisible() && view.shouldTrackHandwritingArea(); } private CursorAnchorInfo getCursorAnchorInfoForConnectionless(View view) { CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder(); // Fake editor views will usually display hint text. The hint text view can be used to // populate the CursorAnchorInfo. TextView textView = findFirstTextViewDescendent(view); if (textView != null) { textView.getCursorAnchorInfo(0, builder, mTempMatrix); if (textView.getSelectionStart() < 0) { // Insertion marker location is not populated if selection start is negative, so // make a best guess. float bottom = textView.getHeight() - textView.getExtendedPaddingBottom(); builder.setInsertionMarkerLocation( /* horizontalPosition= */ textView.getCompoundPaddingStart(), /* lineTop= */ textView.getExtendedPaddingTop(), /* lineBaseline= */ bottom, /* lineBottom= */ bottom, /* flags= */ 0); } } else { // If there is no TextView descendent, just populate the insertion marker with the start // edge of the view. mTempMatrix.reset(); view.transformMatrixToGlobal(mTempMatrix); builder.setMatrix(mTempMatrix); builder.setInsertionMarkerLocation( /* horizontalPosition= */ view.isLayoutRtl() ? view.getWidth() : 0, /* lineTop= */ 0, /* lineBaseline= */ view.getHeight(), /* lineBottom= */ view.getHeight(), /* flags= */ 0); } return builder.build(); } @Nullable private static TextView findFirstTextViewDescendent(View view) { if (view instanceof ViewGroup viewGroup) { TextView textView; for (int i = 0; i < viewGroup.getChildCount(); ++i) { View child = viewGroup.getChildAt(i); textView = (child instanceof TextView tv) ? tv : findFirstTextViewDescendent(viewGroup.getChildAt(i)); if (textView != null && textView.isAggregatedVisible() && (!TextUtils.isEmpty(textView.getText()) || !TextUtils.isEmpty(textView.getHint()))) { return textView; } } } return null; } /** * A class used to track the handwriting areas set by the Views. * * @hide */ @VisibleForTesting public static class HandwritingAreaTracker { private final List mHandwritableViewInfos; public HandwritingAreaTracker() { mHandwritableViewInfos = new ArrayList<>(); } /** * Notify this tracker that the handwriting area of the given view has been updated. * This method does three things: * a) iterate over the all the tracked ViewInfos and remove those already invalid ones. * b) mark the given view's ViewInfo to be dirty. So that next time when * {@link #computeViewInfos} is called, this view's handwriting area will be recomputed. * c) If no the given view is not in the tracked ViewInfo list, a new ViewInfo object will * be created and added to the list. * * @param view the view whose handwriting area is updated. */ public void updateHandwritingAreaForView(@NonNull View view) { Iterator iterator = mHandwritableViewInfos.iterator(); boolean found = false; while (iterator.hasNext()) { final HandwritableViewInfo handwritableViewInfo = iterator.next(); final View curView = handwritableViewInfo.getView(); if (!isViewActive(curView)) { iterator.remove(); } if (curView == view) { found = true; handwritableViewInfo.mIsDirty = true; } } if (!found && isViewActive(view)) { // The given view is not tracked. Create a new HandwritableViewInfo for it and add // to the list. mHandwritableViewInfos.add(new HandwritableViewInfo(view)); } } /** * Update the handwriting areas and return a list of ViewInfos containing the view * reference and its handwriting area. */ @NonNull public List computeViewInfos() { mHandwritableViewInfos.removeIf(viewInfo -> !viewInfo.update()); return mHandwritableViewInfos; } } /** * A class that reference to a View and its handwriting area(in the ViewRoot's coordinate.) * * @hide */ @VisibleForTesting public static class HandwritableViewInfo { final WeakReference mViewRef; Rect mHandwritingArea = null; @VisibleForTesting public boolean mIsDirty = true; @VisibleForTesting public HandwritableViewInfo(@NonNull View view) { mViewRef = new WeakReference<>(view); } /** Return the tracked view. */ @Nullable public View getView() { return mViewRef.get(); } /** * Return the tracked handwriting area, represented in the ViewRoot's coordinates. * Notice, the caller should not modify the returned Rect. */ @Nullable public Rect getHandwritingArea() { return mHandwritingArea; } /** * Update the handwriting area in this ViewInfo. * * @return true if this ViewInfo is still valid. Or false if this ViewInfo has become * invalid due to either view is no longer visible, or the handwriting area set by the * view is removed. {@link HandwritingAreaTracker} no longer need to keep track of this * HandwritableViewInfo this method returns false. */ public boolean update() { final View view = getView(); if (!isViewActive(view)) { return false; } if (!mIsDirty) { return true; } final Rect handwritingArea = view.getHandwritingArea(); if (handwritingArea == null) { return false; } ViewParent parent = view.getParent(); if (parent != null) { if (mHandwritingArea == null) { mHandwritingArea = new Rect(); } mHandwritingArea.set(handwritingArea); if (!parent.getChildVisibleRect(view, mHandwritingArea, null /* offset */)) { mHandwritingArea = null; } } mIsDirty = false; return true; } } private class DelegationCallback implements ConnectionlessHandwritingCallback { private final View mView; private final String mDelegatePackageName; private DelegationCallback(View view, String delegatePackageName) { mView = view; mDelegatePackageName = delegatePackageName; } @Override public void onResult(@NonNull CharSequence text) { mView.getHandwritingDelegatorCallback().run(); } @Override public void onError(int errorCode) { switch (errorCode) { case CONNECTIONLESS_HANDWRITING_ERROR_NO_TEXT_RECOGNIZED: mView.getHandwritingDelegatorCallback().run(); break; case CONNECTIONLESS_HANDWRITING_ERROR_UNSUPPORTED: // Fall back to the old delegation flow mImm.prepareStylusHandwritingDelegation(mView, mDelegatePackageName); mView.getHandwritingDelegatorCallback().run(); break; } } } }