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