script-astra/Android/Sdk/sources/android-35/android/view/HandwritingInitiator.java

1137 lines
47 KiB
Java
Raw Permalink Normal View History

2025-01-20 15:15:20 +00:00
/*
* 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<View> 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<View> 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<View> 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.
*
* <p>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.
*
* <p>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<Boolean> 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<HandwritableViewInfo> 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.
*
* <p> 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. </p>
*
* @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<View> 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<View> 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<HandwritableViewInfo> 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<HandwritableViewInfo> 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<HandwritableViewInfo> 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<View> 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;
}
}
}
}