• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.view;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.graphics.Rect;
22 import android.view.inputmethod.InputMethodManager;
23 
24 import com.android.internal.annotations.VisibleForTesting;
25 
26 import java.lang.ref.WeakReference;
27 import java.util.ArrayList;
28 import java.util.Iterator;
29 import java.util.List;
30 
31 /**
32  * Initiates handwriting mode once it detects stylus movement in handwritable areas.
33  *
34  * It is designed to be used by  {@link ViewRootImpl}. For every stylus related MotionEvent that is
35  * dispatched to view tree, ViewRootImpl should call {@link #onTouchEvent} method of this class.
36  * And it will automatically request to enter the handwriting mode when the conditions meet.
37  *
38  * Notice that ViewRootImpl should still dispatch MotionEvents to view tree as usual.
39  * And if it successfully enters the handwriting mode, the ongoing MotionEvent stream will be
40  * routed to the input method. Input system will fabricate an ACTION_CANCEL and send to
41  * ViewRootImpl.
42  *
43  * This class does nothing if:
44  * a) MotionEvents are not from stylus.
45  * b) The user taps or long-clicks with a stylus etc.
46  * c) Stylus pointer down position is not within a handwritable area.
47  *
48  * Used by InputMethodManager.
49  * @hide
50  */
51 public class HandwritingInitiator {
52     /**
53      * The touchSlop from {@link ViewConfiguration} used to decide whether a pointer is considered
54      * moving or stationary.
55      */
56     private final int mTouchSlop;
57     /**
58      * The timeout used to distinguish tap or long click from handwriting. If the stylus doesn't
59      * move before this timeout, it's not considered as handwriting.
60      */
61     private final long mHandwritingTimeoutInMillis;
62 
63     private State mState = new State();
64     private final HandwritingAreaTracker mHandwritingAreasTracker = new HandwritingAreaTracker();
65 
66     /**
67      * Helper method to reset the internal state of this class.
68      * Calling this method will also prevent the following MotionEvents
69      * triggers handwriting until the next stylus ACTION_DOWN/ACTION_POINTER_DOWN
70      * arrives.
71      */
reset()72     private void reset() {
73         mState = new State();
74     }
75 
76     /** The reference to the View that currently has the input connection. */
77     @Nullable
78     @VisibleForTesting
79     public WeakReference<View> mConnectedView = null;
80 
81     /**
82      * When InputConnection restarts for a View, View#onInputConnectionCreatedInternal
83      * might be called before View#onInputConnectionClosedInternal, so we need to count the input
84      * connections and only set mConnectedView to null when mConnectionCount is zero.
85      */
86     private int mConnectionCount = 0;
87     private final InputMethodManager mImm;
88 
89     @VisibleForTesting
HandwritingInitiator(@onNull ViewConfiguration viewConfiguration, @NonNull InputMethodManager inputMethodManager)90     public HandwritingInitiator(@NonNull ViewConfiguration viewConfiguration,
91             @NonNull InputMethodManager inputMethodManager) {
92         mTouchSlop = viewConfiguration.getScaledTouchSlop();
93         mHandwritingTimeoutInMillis = ViewConfiguration.getLongPressTimeout();
94         mImm = inputMethodManager;
95     }
96 
97     /**
98      * Notify the HandwritingInitiator that a new MotionEvent has arrived.
99      * This method is non-block, and the event passed to this method should be dispatched to the
100      * View tree as usual. If HandwritingInitiator triggers the handwriting mode, an fabricated
101      * ACTION_CANCEL event will be sent to the ViewRootImpl.
102      * @param motionEvent the stylus MotionEvent.
103      */
104     @VisibleForTesting
onTouchEvent(@onNull MotionEvent motionEvent)105     public void onTouchEvent(@NonNull MotionEvent motionEvent) {
106         final int maskedAction = motionEvent.getActionMasked();
107         switch (maskedAction) {
108             case MotionEvent.ACTION_DOWN:
109             case MotionEvent.ACTION_POINTER_DOWN:
110                 final int actionIndex = motionEvent.getActionIndex();
111                 final int toolType = motionEvent.getToolType(actionIndex);
112                 // TOOL_TYPE_ERASER is also from stylus. This indicates that the user is holding
113                 // the eraser button during handwriting.
114                 if (toolType != MotionEvent.TOOL_TYPE_STYLUS
115                         && toolType != MotionEvent.TOOL_TYPE_ERASER) {
116                     // The motion event is not from a stylus event, ignore it.
117                     return;
118                 }
119                 mState.mStylusPointerId = motionEvent.getPointerId(actionIndex);
120                 mState.mStylusDownTimeInMillis = motionEvent.getEventTime();
121                 mState.mStylusDownX = motionEvent.getX(actionIndex);
122                 mState.mStylusDownY = motionEvent.getY(actionIndex);
123                 mState.mShouldInitHandwriting = true;
124                 mState.mExceedTouchSlop = false;
125                 break;
126             case MotionEvent.ACTION_POINTER_UP:
127                 final int pointerId = motionEvent.getPointerId(motionEvent.getActionIndex());
128                 if (pointerId != mState.mStylusPointerId) {
129                     // ACTION_POINTER_UP is from another stylus pointer, ignore the event.
130                     return;
131                 }
132                 // Deliberately fall through.
133             case MotionEvent.ACTION_CANCEL:
134             case MotionEvent.ACTION_UP:
135                 // If it's ACTION_CANCEL or ACTION_UP, all the pointers go up. There is no need to
136                 // check whether the stylus we are tracking goes up.
137                 reset();
138                 break;
139             case MotionEvent.ACTION_MOVE:
140                 // Either we've already tried to initiate handwriting, or the ongoing MotionEvent
141                 // sequence is considered to be tap, long-click or other gestures.
142                 if (!mState.mShouldInitHandwriting || mState.mExceedTouchSlop) {
143                     return;
144                 }
145 
146                 final long timeElapsed =
147                         motionEvent.getEventTime() - mState.mStylusDownTimeInMillis;
148                 if (timeElapsed > mHandwritingTimeoutInMillis) {
149                     reset();
150                     return;
151                 }
152 
153                 final int pointerIndex = motionEvent.findPointerIndex(mState.mStylusPointerId);
154                 final float x = motionEvent.getX(pointerIndex);
155                 final float y = motionEvent.getY(pointerIndex);
156                 if (largerThanTouchSlop(x, y, mState.mStylusDownX, mState.mStylusDownY)) {
157                     mState.mExceedTouchSlop = true;
158                     View candidateView =
159                             findBestCandidateView(mState.mStylusDownX, mState.mStylusDownY);
160                     if (candidateView != null) {
161                         if (candidateView == getConnectedView()) {
162                             startHandwriting(candidateView);
163                         } else {
164                             candidateView.requestFocus();
165                         }
166                     }
167                 }
168         }
169     }
170 
171     @Nullable
getConnectedView()172     private View getConnectedView() {
173         if (mConnectedView == null) return null;
174         return mConnectedView.get();
175     }
176 
clearConnectedView()177     private void clearConnectedView() {
178         mConnectedView = null;
179         mConnectionCount = 0;
180     }
181 
182     /**
183      * Notify HandwritingInitiator that a new InputConnection is created.
184      * The caller of this method should guarantee that each onInputConnectionCreated call
185      * is paired with a onInputConnectionClosed call.
186      * @param view the view that created the current InputConnection.
187      * @see  #onInputConnectionClosed(View)
188      */
onInputConnectionCreated(@onNull View view)189     public void onInputConnectionCreated(@NonNull View view) {
190         if (!view.isAutoHandwritingEnabled()) {
191             clearConnectedView();
192             return;
193         }
194 
195         final View connectedView = getConnectedView();
196         if (connectedView == view) {
197             ++mConnectionCount;
198         } else {
199             mConnectedView = new WeakReference<>(view);
200             mConnectionCount = 1;
201             if (mState.mShouldInitHandwriting) {
202                 tryStartHandwriting();
203             }
204         }
205     }
206 
207     /**
208      * Notify HandwritingInitiator that the InputConnection has closed for the given view.
209      * The caller of this method should guarantee that each onInputConnectionClosed call
210      * is paired with a onInputConnectionCreated call.
211      * @param view the view that closed the InputConnection.
212      */
onInputConnectionClosed(@onNull View view)213     public void onInputConnectionClosed(@NonNull View view) {
214         final View connectedView = getConnectedView();
215         if (connectedView == null) return;
216         if (connectedView == view) {
217             --mConnectionCount;
218             if (mConnectionCount == 0) {
219                 clearConnectedView();
220             }
221         } else {
222             // Unexpected branch, set mConnectedView to null to avoid further problem.
223             clearConnectedView();
224         }
225     }
226 
227     /**
228      * Try to initiate handwriting. For this method to successfully send startHandwriting signal,
229      * the following 3 conditions should meet:
230      *   a) The stylus movement exceeds the touchSlop.
231      *   b) A View has built InputConnection with IME.
232      *   c) The stylus event lands into the connected View's boundary.
233      * This method will immediately fail without any side effect if condition a or b is not met.
234      * However, if both condition a and b are met but the condition c is not met, it will reset the
235      * internal states. And HandwritingInitiator won't attempt to call startHandwriting until the
236      * next ACTION_DOWN.
237      */
tryStartHandwriting()238     private void tryStartHandwriting() {
239         if (!mState.mExceedTouchSlop) {
240             return;
241         }
242         final View connectedView = getConnectedView();
243         if (connectedView == null) {
244             return;
245         }
246 
247         if (!connectedView.isAutoHandwritingEnabled()) {
248             clearConnectedView();
249             return;
250         }
251 
252         final Rect handwritingArea = getViewHandwritingArea(connectedView);
253         if (contains(handwritingArea, mState.mStylusDownX, mState.mStylusDownY)) {
254             startHandwriting(connectedView);
255         } else {
256             reset();
257         }
258     }
259 
260     /** For test only. */
261     @VisibleForTesting
startHandwriting(@onNull View view)262     public void startHandwriting(@NonNull View view) {
263         mImm.startStylusHandwriting(view);
264         reset();
265     }
266 
267     /**
268      * Notify that the handwriting area for the given view might be updated.
269      * @param view the view whose handwriting area might be updated.
270      */
updateHandwritingAreasForView(@onNull View view)271     public void updateHandwritingAreasForView(@NonNull View view) {
272         mHandwritingAreasTracker.updateHandwritingAreaForView(view);
273     }
274 
275     /**
276      * Given the location of the stylus event, return the best candidate view to initialize
277      * handwriting mode.
278      *
279      * @param x the x coordinates of the stylus event, in the coordinates of the window.
280      * @param y the y coordinates of the stylus event, in the coordinates of the window.
281      */
282     @Nullable
findBestCandidateView(float x, float y)283     private View findBestCandidateView(float x, float y) {
284         // If the connectedView is not null and do not set any handwriting area, it will check
285         // whether the connectedView's boundary contains the initial stylus position. If true,
286         // directly return the connectedView.
287         final View connectedView = getConnectedView();
288         if (connectedView != null && connectedView.isAutoHandwritingEnabled()) {
289             final Rect handwritingArea = getViewHandwritingArea(connectedView);
290             if (contains(handwritingArea, x, y)) {
291                 return connectedView;
292             }
293         }
294 
295         // Check the registered handwriting areas.
296         final List<HandwritableViewInfo> handwritableViewInfos =
297                 mHandwritingAreasTracker.computeViewInfos();
298         for (HandwritableViewInfo viewInfo : handwritableViewInfos) {
299             final View view = viewInfo.getView();
300             if (!view.isAutoHandwritingEnabled()) continue;
301             if (contains(viewInfo.getHandwritingArea(), x, y)) {
302                 return viewInfo.getView();
303             }
304         }
305         return null;
306     }
307 
308     /**
309      * Return the handwriting area of the given view, represented in the window's coordinate.
310      * If the view didn't set any handwriting area, it will return the view's boundary.
311      * It will return null if the view or its handwriting area is not visible.
312      */
313     @Nullable
getViewHandwritingArea(@onNull View view)314     private static Rect getViewHandwritingArea(@NonNull View view) {
315         final ViewParent viewParent = view.getParent();
316         if (viewParent != null && view.isAttachedToWindow() && view.isAggregatedVisible()) {
317             final Rect localHandwritingArea = view.getHandwritingArea();
318             final Rect globalHandwritingArea = new Rect();
319             if (localHandwritingArea != null) {
320                 globalHandwritingArea.set(localHandwritingArea);
321             } else {
322                 globalHandwritingArea.set(0, 0, view.getWidth(), view.getHeight());
323             }
324             if (viewParent.getChildVisibleRect(view, globalHandwritingArea, null)) {
325                 return globalHandwritingArea;
326             }
327         }
328         return null;
329     }
330 
331     /**
332      * Return true if the (x, y) is inside by the given {@link Rect}.
333      */
contains(@ullable Rect rect, float x, float y)334     private boolean contains(@Nullable Rect rect, float x, float y) {
335         if (rect == null) return false;
336         return x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
337     }
338 
largerThanTouchSlop(float x1, float y1, float x2, float y2)339     private boolean largerThanTouchSlop(float x1, float y1, float x2, float y2) {
340         float dx = x1 - x2;
341         float dy = y1 - y2;
342         return dx * dx + dy * dy > mTouchSlop * mTouchSlop;
343     }
344 
345     /** Object that keeps the MotionEvent related states for HandwritingInitiator. */
346     private static class State {
347         /**
348          * Whether it should initiate handwriting mode for the current MotionEvent sequence.
349          * (A series of MotionEvents from ACTION_DOWN to ACTION_UP)
350          *
351          * The purpose of this boolean value is:
352          * a) We should only request to start handwriting mode ONCE for each MotionEvent sequence.
353          * If we've already requested to enter handwriting mode for the ongoing MotionEvent
354          * sequence, this boolean is set to false. And it won't request to start handwriting again.
355          *
356          * b) If the MotionEvent sequence is considered to be tap, long-click or other gestures.
357          * This boolean will be set to false, and it won't request to start handwriting.
358          */
359         private boolean mShouldInitHandwriting = false;
360         /**
361          * Whether the current ongoing stylus MotionEvent sequence already exceeds the touchSlop.
362          * It's used for the case where the stylus exceeds touchSlop before the target View built
363          * InputConnection.
364          */
365         private boolean mExceedTouchSlop = false;
366 
367         /** The pointer id of the stylus pointer that is being tracked. */
368         private int mStylusPointerId = -1;
369         /** The time stamp when the stylus pointer goes down. */
370         private long mStylusDownTimeInMillis = -1;
371         /** The initial location where the stylus pointer goes down. */
372         private float mStylusDownX = Float.NaN;
373         private float mStylusDownY = Float.NaN;
374     }
375 
376     /** The helper method to check if the given view is still active for handwriting. */
isViewActive(@ullable View view)377     private static boolean isViewActive(@Nullable View view) {
378         return view != null && view.isAttachedToWindow() && view.isAggregatedVisible()
379                 && view.isAutoHandwritingEnabled();
380     }
381 
382     /**
383      * A class used to track the handwriting areas set by the Views.
384      *
385      * @hide
386      */
387     @VisibleForTesting
388     public static class HandwritingAreaTracker {
389         private final List<HandwritableViewInfo> mHandwritableViewInfos;
390 
HandwritingAreaTracker()391         public HandwritingAreaTracker() {
392             mHandwritableViewInfos = new ArrayList<>();
393         }
394 
395         /**
396          * Notify this tracker that the handwriting area of the given view has been updated.
397          * This method does three things:
398          * a) iterate over the all the tracked ViewInfos and remove those already invalid ones.
399          * b) mark the given view's ViewInfo to be dirty. So that next time when
400          * {@link #computeViewInfos} is called, this view's handwriting area will be recomputed.
401          * c) If no the given view is not in the tracked ViewInfo list, a new ViewInfo object will
402          * be created and added to the list.
403          *
404          * @param view the view whose handwriting area is updated.
405          */
updateHandwritingAreaForView(@onNull View view)406         public void updateHandwritingAreaForView(@NonNull View view) {
407             Iterator<HandwritableViewInfo> iterator = mHandwritableViewInfos.iterator();
408             boolean found = false;
409             while (iterator.hasNext()) {
410                 final HandwritableViewInfo handwritableViewInfo = iterator.next();
411                 final View curView = handwritableViewInfo.getView();
412                 if (!isViewActive(curView)) {
413                     iterator.remove();
414                 }
415                 if (curView == view) {
416                     found = true;
417                     handwritableViewInfo.mIsDirty = true;
418                 }
419             }
420             if (!found && isViewActive(view)) {
421                 // The given view is not tracked. Create a new HandwritableViewInfo for it and add
422                 // to the list.
423                 mHandwritableViewInfos.add(new HandwritableViewInfo(view));
424             }
425         }
426 
427         /**
428          * Update the handwriting areas and return a list of ViewInfos containing the view
429          * reference and its handwriting area.
430          */
431         @NonNull
computeViewInfos()432         public List<HandwritableViewInfo> computeViewInfos() {
433             mHandwritableViewInfos.removeIf(viewInfo -> !viewInfo.update());
434             return mHandwritableViewInfos;
435         }
436     }
437 
438     /**
439      * A class that reference to a View and its handwriting area(in the ViewRoot's coordinate.)
440      *
441      * @hide
442      */
443     @VisibleForTesting
444     public static class HandwritableViewInfo {
445         final WeakReference<View> mViewRef;
446         Rect mHandwritingArea = null;
447         @VisibleForTesting
448         public boolean mIsDirty = true;
449 
450         @VisibleForTesting
HandwritableViewInfo(@onNull View view)451         public HandwritableViewInfo(@NonNull View view) {
452             mViewRef = new WeakReference<>(view);
453         }
454 
455         /** Return the tracked view. */
456         @Nullable
getView()457         public View getView() {
458             return mViewRef.get();
459         }
460 
461         /**
462          * Return the tracked handwriting area, represented in the ViewRoot's coordinates.
463          * Notice, the caller should not modify the returned Rect.
464          */
465         @Nullable
getHandwritingArea()466         public Rect getHandwritingArea() {
467             return mHandwritingArea;
468         }
469 
470         /**
471          * Update the handwriting area in this ViewInfo.
472          *
473          * @return true if this ViewInfo is still valid. Or false if this ViewInfo has become
474          * invalid due to either view is no longer visible, or the handwriting area set by the
475          * view is removed. {@link HandwritingAreaTracker} no longer need to keep track of this
476          * HandwritableViewInfo this method returns false.
477          */
update()478         public boolean update() {
479             final View view = getView();
480             if (!isViewActive(view)) {
481                 return false;
482             }
483 
484             if (!mIsDirty) {
485                 return true;
486             }
487             final Rect handwritingArea = view.getHandwritingArea();
488             if (handwritingArea == null) {
489                 return false;
490             }
491 
492             ViewParent parent = view.getParent();
493             if (parent != null) {
494                 if (mHandwritingArea == null) {
495                     mHandwritingArea = new Rect();
496                 }
497                 mHandwritingArea.set(handwritingArea);
498                 if (!parent.getChildVisibleRect(view, mHandwritingArea, null /* offset */)) {
499                     mHandwritingArea = null;
500                 }
501             }
502             mIsDirty = false;
503             return true;
504         }
505     }
506 }
507