1 /* 2 * Copyright (C) 2020 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 package com.android.car.rotary; 17 18 import static android.app.ActivityTaskManager.INVALID_TASK_ID; 19 import static android.view.View.FOCUS_DOWN; 20 import static android.view.View.FOCUS_LEFT; 21 import static android.view.View.FOCUS_RIGHT; 22 import static android.view.View.FOCUS_UP; 23 import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD; 24 import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD; 25 import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT; 26 import static android.view.accessibility.AccessibilityWindowInfo.TYPE_APPLICATION; 27 import static android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD; 28 29 import static com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport.BOILERPLATE_CODE; 30 import static com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport.DUMP_INFO; 31 32 import android.content.pm.PackageManager; 33 import android.graphics.Rect; 34 import android.view.Display; 35 import android.view.View; 36 import android.view.accessibility.AccessibilityNodeInfo; 37 import android.view.accessibility.AccessibilityWindowInfo; 38 39 import androidx.annotation.NonNull; 40 import androidx.annotation.Nullable; 41 import androidx.annotation.VisibleForTesting; 42 43 import com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport; 44 import com.android.car.ui.FocusArea; 45 import com.android.car.ui.FocusParkingView; 46 import com.android.internal.util.dump.DualDumpOutputStream; 47 48 import java.util.ArrayList; 49 import java.util.Iterator; 50 import java.util.List; 51 import java.util.function.Predicate; 52 53 /** 54 * A helper class used for finding the next focusable node when the rotary controller is rotated or 55 * nudged. 56 */ 57 class Navigator { 58 59 @NonNull 60 private NodeCopier mNodeCopier = new NodeCopier(); 61 62 @NonNull 63 private final TreeTraverser mTreeTraverser = new TreeTraverser(); 64 65 @NonNull 66 @VisibleForTesting 67 final SurfaceViewHelper mSurfaceViewHelper = new SurfaceViewHelper(); 68 69 private final int mHunLeft; 70 private final int mHunRight; 71 72 @View.FocusRealDirection 73 private int mHunNudgeDirection; 74 75 @NonNull 76 private final Rect mAppWindowBounds; 77 78 private int mAppWindowTaskId = INVALID_TASK_ID; 79 Navigator(int displayWidth, int displayHeight, int hunLeft, int hunRight, boolean showHunOnBottom)80 Navigator(int displayWidth, int displayHeight, int hunLeft, int hunRight, 81 boolean showHunOnBottom) { 82 mHunLeft = hunLeft; 83 mHunRight = hunRight; 84 mHunNudgeDirection = showHunOnBottom ? FOCUS_DOWN : FOCUS_UP; 85 mAppWindowBounds = new Rect(0, 0, displayWidth, displayHeight); 86 } 87 88 @VisibleForTesting Navigator()89 Navigator() { 90 this(0, 0, 0, 0, false); 91 } 92 93 /** 94 * Updates {@link #mAppWindowTaskId} if {@code window} is a full-screen app window on the 95 * default display. 96 */ updateAppWindowTaskId(@onNull AccessibilityWindowInfo window)97 void updateAppWindowTaskId(@NonNull AccessibilityWindowInfo window) { 98 if (window.getType() == TYPE_APPLICATION 99 && window.getDisplayId() == Display.DEFAULT_DISPLAY) { 100 Rect windowBounds = new Rect(); 101 window.getBoundsInScreen(windowBounds); 102 if (mAppWindowBounds.equals(windowBounds)) { 103 mAppWindowTaskId = window.getTaskId(); 104 L.d("Task ID of app window: " + mAppWindowTaskId); 105 } 106 } 107 } 108 109 /** Initializes the package name of the host app. */ initHostApp(@onNull PackageManager packageManager)110 void initHostApp(@NonNull PackageManager packageManager) { 111 mSurfaceViewHelper.initHostApp(packageManager); 112 } 113 114 /** Clears the package name of the host app if the given {@code packageName} matches. */ clearHostApp(@onNull String packageName)115 void clearHostApp(@NonNull String packageName) { 116 mSurfaceViewHelper.clearHostApp(packageName); 117 } 118 119 /** Adds the package name of the client app. */ addClientApp(@onNull CharSequence clientAppPackageName)120 void addClientApp(@NonNull CharSequence clientAppPackageName) { 121 mSurfaceViewHelper.addClientApp(clientAppPackageName); 122 } 123 124 /** Returns whether the given {@code node} represents a view of the host app. */ isHostNode(@onNull AccessibilityNodeInfo node)125 boolean isHostNode(@NonNull AccessibilityNodeInfo node) { 126 return mSurfaceViewHelper.isHostNode(node); 127 } 128 129 /** Returns whether the given {@code node} represents a view of the client app. */ isClientNode(@onNull AccessibilityNodeInfo node)130 boolean isClientNode(@NonNull AccessibilityNodeInfo node) { 131 return mSurfaceViewHelper.isClientNode(node); 132 } 133 134 @Nullable findHunWindow(@onNull List<AccessibilityWindowInfo> windows)135 AccessibilityWindowInfo findHunWindow(@NonNull List<AccessibilityWindowInfo> windows) { 136 for (AccessibilityWindowInfo window : windows) { 137 if (isHunWindow(window)) { 138 return window; 139 } 140 } 141 return null; 142 } 143 144 /** 145 * Returns the target focusable for a rotate. The caller is responsible for recycling the node 146 * in the result. 147 * 148 * <p>Limits navigation to focusable views within a scrollable container's viewport, if any. 149 * 150 * @param sourceNode the current focus 151 * @param direction rotate direction, must be {@link View#FOCUS_FORWARD} or {@link 152 * View#FOCUS_BACKWARD} 153 * @param rotationCount the number of "ticks" to rotate. Only count nodes that can take focus 154 * (visible, focusable and enabled). If {@code skipNode} is encountered, it 155 * isn't counted. 156 * @return a FindRotateTargetResult containing a node and a count of the number of times the 157 * search advanced to another node. The node represents a focusable view in the given 158 * {@code direction} from the current focus within the same {@link FocusArea}. If the 159 * first or last view is reached before counting up to {@code rotationCount}, the first 160 * or last view is returned. However, if there are no views that can take focus in the 161 * given {@code direction}, {@code null} is returned. 162 */ 163 @Nullable findRotateTarget( @onNull AccessibilityNodeInfo sourceNode, int direction, int rotationCount)164 FindRotateTargetResult findRotateTarget( 165 @NonNull AccessibilityNodeInfo sourceNode, int direction, int rotationCount) { 166 int advancedCount = 0; 167 AccessibilityNodeInfo currentFocusArea = getAncestorFocusArea(sourceNode); 168 AccessibilityNodeInfo candidate = copyNode(sourceNode); 169 AccessibilityNodeInfo target = null; 170 while (advancedCount < rotationCount) { 171 AccessibilityNodeInfo nextCandidate = null; 172 AccessibilityNodeInfo webView = findWebViewAncestor(candidate); 173 if (webView != null) { 174 nextCandidate = findNextFocusableInWebView(webView, candidate, direction); 175 } 176 if (nextCandidate == null) { 177 // If we aren't in a WebView or there aren't any more focusable nodes within the 178 // WebView, use focusSearch(). 179 nextCandidate = candidate.focusSearch(direction); 180 } 181 AccessibilityNodeInfo candidateFocusArea = 182 nextCandidate == null ? null : getAncestorFocusArea(nextCandidate); 183 184 // Only advance to nextCandidate if: 185 // 1. it's in the same focus area, 186 // 2. and it isn't a FocusParkingView (this is to prevent wrap-around when there is only 187 // one focus area in the window, including when the root node is treated as a focus 188 // area), 189 // 3. and nextCandidate is different from candidate (if sourceNode is the first 190 // focusable node in the window, searching backward will return sourceNode itself). 191 if (nextCandidate != null && currentFocusArea.equals(candidateFocusArea) 192 && !Utils.isFocusParkingView(nextCandidate) 193 && !nextCandidate.equals(candidate)) { 194 // We need to skip nextTargetNode if: 195 // 1. it can't perform focus action (focusSearch() may return a node with zero 196 // width and height), 197 // 2. or it is a scrollable container but it shouldn't be scrolled (i.e., it is not 198 // scrollable, or its descendants can take focus). 199 // When we want to focus on its element directly, we'll skip the container. When 200 // we want to focus on container and scroll it, we won't skip the container. 201 if (!Utils.canPerformFocus(nextCandidate) 202 || (Utils.isScrollableContainer(nextCandidate) 203 && !Utils.canScrollableContainerTakeFocus(nextCandidate))) { 204 Utils.recycleNode(candidate); 205 Utils.recycleNode(candidateFocusArea); 206 candidate = nextCandidate; 207 continue; 208 } 209 210 // If we're navigating in a scrollable container that can scroll in the specified 211 // direction and the next candidate is off-screen or there are no more focusable 212 // views within the scrollable container, stop navigating so that any remaining 213 // detents are used for scrolling. 214 AccessibilityNodeInfo scrollableContainer = findScrollableContainer(candidate); 215 AccessibilityNodeInfo.AccessibilityAction scrollAction = 216 direction == View.FOCUS_FORWARD 217 ? ACTION_SCROLL_FORWARD 218 : ACTION_SCROLL_BACKWARD; 219 if (scrollableContainer != null 220 && scrollableContainer.getActionList().contains(scrollAction) 221 && (!Utils.isDescendant(scrollableContainer, nextCandidate) 222 || Utils.getBoundsInScreen(nextCandidate).isEmpty())) { 223 Utils.recycleNode(nextCandidate); 224 Utils.recycleNode(candidateFocusArea); 225 break; 226 } 227 Utils.recycleNode(scrollableContainer); 228 229 Utils.recycleNode(candidate); 230 Utils.recycleNode(candidateFocusArea); 231 candidate = nextCandidate; 232 Utils.recycleNode(target); 233 target = copyNode(candidate); 234 advancedCount++; 235 } else { 236 Utils.recycleNode(nextCandidate); 237 Utils.recycleNode(candidateFocusArea); 238 break; 239 } 240 } 241 currentFocusArea.recycle(); 242 candidate.recycle(); 243 if (sourceNode.equals(target)) { 244 L.e("Wrap-around on the same node"); 245 target.recycle(); 246 return null; 247 } 248 return target == null ? null : new FindRotateTargetResult(target, advancedCount); 249 } 250 251 /** Sets a NodeCopier instance for testing. */ 252 @VisibleForTesting setNodeCopier(@onNull NodeCopier nodeCopier)253 void setNodeCopier(@NonNull NodeCopier nodeCopier) { 254 mNodeCopier = nodeCopier; 255 mTreeTraverser.setNodeCopier(nodeCopier); 256 } 257 258 /** 259 * Returns the root node in the tree containing {@code node}. The caller is responsible for 260 * recycling the result. 261 */ 262 @NonNull getRoot(@onNull AccessibilityNodeInfo node)263 AccessibilityNodeInfo getRoot(@NonNull AccessibilityNodeInfo node) { 264 // If the node represents a view in the embedded view hierarchy hosted by a SurfaceView, 265 // return the root node of the hierarchy, which is the only child of the SurfaceView node. 266 if (isHostNode(node)) { 267 AccessibilityNodeInfo child = mNodeCopier.copy(node); 268 AccessibilityNodeInfo parent = node.getParent(); 269 while (parent != null && !Utils.isSurfaceView(parent)) { 270 child.recycle(); 271 child = parent; 272 parent = child.getParent(); 273 } 274 Utils.recycleNode(parent); 275 return child; 276 } 277 278 // Get the root node directly via the window. 279 AccessibilityWindowInfo window = node.getWindow(); 280 if (window != null) { 281 AccessibilityNodeInfo root = window.getRoot(); 282 window.recycle(); 283 if (root != null) { 284 return root; 285 } 286 } 287 288 // If the root node can't be accessed via the window, navigate up the node tree. 289 AccessibilityNodeInfo child = mNodeCopier.copy(node); 290 AccessibilityNodeInfo parent = node.getParent(); 291 while (parent != null) { 292 child.recycle(); 293 child = parent; 294 parent = child.getParent(); 295 } 296 return child; 297 } 298 299 /** 300 * Searches {@code root} and its descendants, and returns the currently focused node if it's 301 * not a {@link FocusParkingView}, or returns null in other cases. The caller is responsible 302 * for recycling the result. 303 */ 304 @Nullable findFocusedNodeInRoot(@onNull AccessibilityNodeInfo root)305 AccessibilityNodeInfo findFocusedNodeInRoot(@NonNull AccessibilityNodeInfo root) { 306 AccessibilityNodeInfo focusedNode = findFocusedNodeInRootInternal(root); 307 if (focusedNode != null && Utils.isFocusParkingView(focusedNode)) { 308 focusedNode.recycle(); 309 return null; 310 } 311 return focusedNode; 312 } 313 314 /** 315 * Searches {@code root} and its descendants, and returns the currently focused node, if any, 316 * or returns null if not found. The caller is responsible for recycling the result. 317 */ 318 @Nullable findFocusedNodeInRootInternal( @onNull AccessibilityNodeInfo root)319 private AccessibilityNodeInfo findFocusedNodeInRootInternal( 320 @NonNull AccessibilityNodeInfo root) { 321 AccessibilityNodeInfo surfaceView = null; 322 if (!isClientNode(root)) { 323 AccessibilityNodeInfo focusedNode = root.findFocus(FOCUS_INPUT); 324 if (focusedNode != null && Utils.isSurfaceView(focusedNode)) { 325 // The focused node represents a SurfaceView. In this case the root node is actually 326 // a client node but Navigator doesn't know that because SurfaceViewHelper doesn't 327 // know the package name of the client app. 328 // Although the package name of the client app will be stored in SurfaceViewHelper 329 // when RotaryService handles TYPE_WINDOW_STATE_CHANGED event, RotaryService may not 330 // receive the event. For example, RotaryService may have been killed and restarted. 331 // In this case, Navigator should store the package name. 332 surfaceView = focusedNode; 333 addClientApp(surfaceView.getPackageName()); 334 } else { 335 return focusedNode; 336 } 337 } 338 339 // The root node is in client app, which contains a SurfaceView to display the embedded 340 // view hierarchy. In this case only search inside the embedded view hierarchy. 341 if (surfaceView == null) { 342 surfaceView = findSurfaceViewInRoot(root); 343 } 344 if (surfaceView == null) { 345 L.w("Failed to find SurfaceView in client app " + root); 346 return null; 347 } 348 if (surfaceView.getChildCount() == 0) { 349 L.d("Host app is not loaded yet"); 350 surfaceView.recycle(); 351 return null; 352 } 353 AccessibilityNodeInfo embeddedRoot = surfaceView.getChild(0); 354 surfaceView.recycle(); 355 if (embeddedRoot == null) { 356 L.w("Failed to get the root of host app"); 357 return null; 358 } 359 AccessibilityNodeInfo focusedNode = embeddedRoot.findFocus(FOCUS_INPUT); 360 embeddedRoot.recycle(); 361 return focusedNode; 362 } 363 364 /** 365 * Searches the window containing {@code node}, and returns the node representing a {@link 366 * FocusParkingView}, if any, or returns null if not found. The caller is responsible for 367 * recycling the result. 368 */ 369 @Nullable findFocusParkingView(@onNull AccessibilityNodeInfo node)370 AccessibilityNodeInfo findFocusParkingView(@NonNull AccessibilityNodeInfo node) { 371 AccessibilityNodeInfo root = getRoot(node); 372 AccessibilityNodeInfo fpv = findFocusParkingViewInRoot(root); 373 root.recycle(); 374 return fpv; 375 } 376 377 /** 378 * Searches {@code root} and its descendants, and returns the node representing a {@link 379 * FocusParkingView}, if any, or returns null if not found. The caller is responsible for 380 * recycling the result. 381 */ 382 @Nullable findFocusParkingViewInRoot(@onNull AccessibilityNodeInfo root)383 AccessibilityNodeInfo findFocusParkingViewInRoot(@NonNull AccessibilityNodeInfo root) { 384 return mTreeTraverser.depthFirstSearch( 385 root, 386 /* skipPredicate= */ Utils::isFocusArea, 387 /* targetPredicate= */ Utils::isFocusParkingView 388 ); 389 } 390 391 /** 392 * Searches {@code root} and its descendants, and returns the node representing a {@link 393 * android.view.SurfaceView}, if any, or returns null if not found. The caller is responsible 394 * for recycling the result. 395 */ 396 @Nullable findSurfaceViewInRoot(@onNull AccessibilityNodeInfo root)397 AccessibilityNodeInfo findSurfaceViewInRoot(@NonNull AccessibilityNodeInfo root) { 398 return mTreeTraverser.depthFirstSearch(root, /* targetPredicate= */ Utils::isSurfaceView); 399 } 400 401 /** 402 * Returns the best target focus area for a nudge in the given {@code direction}. The caller is 403 * responsible for recycling the result. 404 * 405 * @param windows a list of windows to search from 406 * @param sourceNode the current focus 407 * @param currentFocusArea the current focus area 408 * @param direction nudge direction, must be {@link View#FOCUS_UP}, {@link 409 * View#FOCUS_DOWN}, {@link View#FOCUS_LEFT}, or {@link 410 * View#FOCUS_RIGHT} 411 */ findNudgeTargetFocusArea( @onNull List<AccessibilityWindowInfo> windows, @NonNull AccessibilityNodeInfo sourceNode, @NonNull AccessibilityNodeInfo currentFocusArea, int direction)412 AccessibilityNodeInfo findNudgeTargetFocusArea( 413 @NonNull List<AccessibilityWindowInfo> windows, 414 @NonNull AccessibilityNodeInfo sourceNode, 415 @NonNull AccessibilityNodeInfo currentFocusArea, 416 int direction) { 417 AccessibilityWindowInfo currentWindow = sourceNode.getWindow(); 418 if (currentWindow == null) { 419 L.e("Currently focused window is null"); 420 return null; 421 } 422 423 // Build a list of candidate focus areas, starting with all the other focus areas in the 424 // same window as the current focus area. 425 List<AccessibilityNodeInfo> candidateFocusAreas = findFocusAreas(currentWindow); 426 for (AccessibilityNodeInfo focusArea : candidateFocusAreas) { 427 if (focusArea.equals(currentFocusArea)) { 428 candidateFocusAreas.remove(focusArea); 429 focusArea.recycle(); 430 break; 431 } 432 } 433 434 // Exclude focus areas that have no descendants to take focus, because once we found a best 435 // candidate focus area, we don't dig into other ones. If it has no descendants to take 436 // focus, the nudge will fail. 437 removeEmptyFocusAreas(candidateFocusAreas); 438 439 if (currentWindow.getType() != TYPE_INPUT_METHOD 440 || shouldNudgeOutOfIme(sourceNode, currentFocusArea, candidateFocusAreas, 441 direction)) { 442 // Add candidate focus areas in other windows in the given direction. 443 List<AccessibilityWindowInfo> candidateWindows = new ArrayList<>(); 444 boolean isSourceNodeEditable = sourceNode.isEditable(); 445 addWindowsInDirection(windows, currentWindow, candidateWindows, direction, 446 isSourceNodeEditable); 447 currentWindow.recycle(); 448 for (AccessibilityWindowInfo window : candidateWindows) { 449 List<AccessibilityNodeInfo> focusAreasInAnotherWindow = findFocusAreas(window); 450 candidateFocusAreas.addAll(focusAreasInAnotherWindow); 451 } 452 453 // Exclude focus areas that have no descendants to take focus, because once we found a 454 // best candidate focus area, we don't dig into other ones. If it has no descendants to 455 // take focus, the nudge will fail. 456 removeEmptyFocusAreas(candidateFocusAreas); 457 } 458 459 // Choose the best candidate as our target focus area. 460 AccessibilityNodeInfo targetFocusArea = 461 chooseBestNudgeCandidate(sourceNode, candidateFocusAreas, direction); 462 Utils.recycleNodes(candidateFocusAreas); 463 return targetFocusArea; 464 } 465 466 /** 467 * Returns whether it should nudge out the IME window. If the current window is IME window and 468 * there are candidate FocusAreas in it for the given direction, it shouldn't nudge out of the 469 * IME window. 470 */ shouldNudgeOutOfIme(@onNull AccessibilityNodeInfo sourceNode, @NonNull AccessibilityNodeInfo currentFocusArea, @NonNull List<AccessibilityNodeInfo> focusAreasInCurrentWindow, int direction)471 private boolean shouldNudgeOutOfIme(@NonNull AccessibilityNodeInfo sourceNode, 472 @NonNull AccessibilityNodeInfo currentFocusArea, 473 @NonNull List<AccessibilityNodeInfo> focusAreasInCurrentWindow, 474 int direction) { 475 if (!focusAreasInCurrentWindow.isEmpty()) { 476 Rect sourceBounds = Utils.getBoundsInScreen(sourceNode); 477 Rect sourceFocusAreaBounds = Utils.getBoundsInScreen(currentFocusArea); 478 for (AccessibilityNodeInfo candidate : focusAreasInCurrentWindow) { 479 if (isCandidate(sourceBounds, sourceFocusAreaBounds, candidate, direction)) { 480 return false; 481 } 482 } 483 } 484 return true; 485 } 486 removeEmptyFocusAreas(@onNull List<AccessibilityNodeInfo> focusAreas)487 private void removeEmptyFocusAreas(@NonNull List<AccessibilityNodeInfo> focusAreas) { 488 for (Iterator<AccessibilityNodeInfo> iterator = focusAreas.iterator(); 489 iterator.hasNext(); ) { 490 AccessibilityNodeInfo focusArea = iterator.next(); 491 if (!Utils.canHaveFocus(focusArea) 492 && !containsWebViewWithFocusableDescendants(focusArea)) { 493 iterator.remove(); 494 focusArea.recycle(); 495 } 496 } 497 } 498 containsWebViewWithFocusableDescendants(@onNull AccessibilityNodeInfo node)499 private boolean containsWebViewWithFocusableDescendants(@NonNull AccessibilityNodeInfo node) { 500 List<AccessibilityNodeInfo> webViews = new ArrayList<>(); 501 mTreeTraverser.depthFirstSelect(node, Utils::isWebView, webViews); 502 if (webViews.isEmpty()) { 503 return false; 504 } 505 boolean hasFocusableDescendant = false; 506 for (AccessibilityNodeInfo webView : webViews) { 507 AccessibilityNodeInfo focusableDescendant = mTreeTraverser.depthFirstSearch(webView, 508 Utils::canPerformFocus); 509 if (focusableDescendant != null) { 510 hasFocusableDescendant = true; 511 focusableDescendant.recycle(); 512 break; 513 } 514 } 515 Utils.recycleNodes(webViews); 516 return hasFocusableDescendant; 517 } 518 519 /** 520 * Adds all the {@code windows} in the given {@code direction} of the given {@code source} 521 * window to the given list if the {@code source} window is not an overlay. If it's an overlay 522 * and the source node is editable, adds the IME window only. Otherwise does nothing. 523 */ addWindowsInDirection(@onNull List<AccessibilityWindowInfo> windows, @NonNull AccessibilityWindowInfo source, @NonNull List<AccessibilityWindowInfo> results, int direction, boolean isSourceNodeEditable)524 private void addWindowsInDirection(@NonNull List<AccessibilityWindowInfo> windows, 525 @NonNull AccessibilityWindowInfo source, 526 @NonNull List<AccessibilityWindowInfo> results, 527 int direction, 528 boolean isSourceNodeEditable) { 529 Rect sourceBounds = new Rect(); 530 source.getBoundsInScreen(sourceBounds); 531 boolean isSourceWindowOverlayWindow = isOverlayWindow(source, sourceBounds); 532 Rect destBounds = new Rect(); 533 for (AccessibilityWindowInfo window : windows) { 534 if (window.equals(source)) { 535 continue; 536 } 537 // Nudging out of the overlay window is not allowed unless the source node is editable 538 // and the target window is an IME window. E.g., nudging from the EditText in the Dialog 539 // to the IME is allowed, while nudging from the Button in the Dialog to the IME is not 540 // allowed. 541 if (isSourceWindowOverlayWindow 542 && (!isSourceNodeEditable || window.getType() != TYPE_INPUT_METHOD)) { 543 continue; 544 } 545 546 window.getBoundsInScreen(destBounds); 547 // Even if only part of destBounds is in the given direction of sourceBounds, we 548 // still include it because that part may contain the target focus area. 549 if (FocusFinder.isPartiallyInDirection(sourceBounds, destBounds, direction)) { 550 results.add(window); 551 } 552 } 553 } 554 555 /** 556 * Returns whether the given {@code window} with the given {@code bounds} is an overlay window. 557 * <p> 558 * If the source window is an application window on the default display and it's smaller than 559 the display, then it's either a TaskView window or an overlay window (such as a Dialog 560 window). The ID of a TaskView task is different from the full screen application, while 561 the ID of an overlay task is the same with the full screen application, so task ID is used 562 to decide whether it's an overlay window. 563 */ isOverlayWindow(@onNull AccessibilityWindowInfo window, @NonNull Rect bounds)564 private boolean isOverlayWindow(@NonNull AccessibilityWindowInfo window, @NonNull Rect bounds) { 565 return window.getType() == TYPE_APPLICATION 566 && window.getDisplayId() == Display.DEFAULT_DISPLAY 567 && !mAppWindowBounds.equals(bounds) 568 && window.getTaskId() == mAppWindowTaskId; 569 } 570 571 /** 572 * Returns whether nudging to the given {@code direction} can dismiss the given {@code window} 573 * with the given {@code bounds}. 574 */ isDismissible(@onNull AccessibilityWindowInfo window, @NonNull Rect bounds, @View.FocusRealDirection int direction)575 boolean isDismissible(@NonNull AccessibilityWindowInfo window, 576 @NonNull Rect bounds, 577 @View.FocusRealDirection int direction) { 578 // Only overlay windows can be dismissed. 579 if (!isOverlayWindow(window, bounds)) { 580 return false; 581 } 582 // The window can be dismissed when part of the underlying window is not covered by it in 583 // the given direction. 584 switch (direction) { 585 case FOCUS_UP: 586 return mAppWindowBounds.top < bounds.top; 587 case FOCUS_DOWN: 588 return mAppWindowBounds.bottom > bounds.bottom; 589 case FOCUS_LEFT: 590 return mAppWindowBounds.left < bounds.left; 591 case FOCUS_RIGHT: 592 return mAppWindowBounds.right > bounds.right; 593 } 594 return false; 595 } 596 597 /** 598 * Scans the view hierarchy of the given {@code window} looking for focus areas and returns 599 * them. If there are no explicitly declared {@link FocusArea}s, returns the root view. The 600 * caller is responsible for recycling the result. 601 */ 602 @NonNull 603 @VisibleForTesting findFocusAreas(@onNull AccessibilityWindowInfo window)604 List<AccessibilityNodeInfo> findFocusAreas(@NonNull AccessibilityWindowInfo window) { 605 List<AccessibilityNodeInfo> results = new ArrayList<>(); 606 AccessibilityNodeInfo rootNode = window.getRoot(); 607 if (rootNode != null) { 608 // If the root node is in the client app therefore contains a SurfaceView, skip the view 609 // hierarchy of the client app, and scan the view hierarchy of the host app, which is 610 // embedded in the SurfaceView. 611 if (isClientNode(rootNode)) { 612 AccessibilityNodeInfo hostRoot = getDescendantHostRoot(rootNode); 613 rootNode.recycle(); 614 rootNode = hostRoot; 615 } 616 617 addFocusAreas(rootNode, results); 618 if (results.isEmpty()) { 619 results.add(copyNode(rootNode)); 620 } 621 rootNode.recycle(); 622 } 623 return results; 624 } 625 626 /** 627 * Searches from {@code clientNode}, and returns the root of the embedded view hierarchy if any, 628 * or returns null if not found. The caller is responsible for recycling the result. 629 */ 630 @Nullable getDescendantHostRoot(@onNull AccessibilityNodeInfo clientNode)631 AccessibilityNodeInfo getDescendantHostRoot(@NonNull AccessibilityNodeInfo clientNode) { 632 return mTreeTraverser.depthFirstSearch(clientNode, this::isHostNode); 633 } 634 635 /** 636 * Returns whether the given window is the Heads-up Notification (HUN) window. The HUN window 637 * is identified by the left and right edges. The top and bottom vary depending on whether the 638 * HUN appears at the top or bottom of the screen and on the height of the notification being 639 * displayed so they aren't used. 640 */ isHunWindow(@ullable AccessibilityWindowInfo window)641 boolean isHunWindow(@Nullable AccessibilityWindowInfo window) { 642 if (window == null || window.getType() != AccessibilityWindowInfo.TYPE_SYSTEM) { 643 return false; 644 } 645 Rect bounds = new Rect(); 646 window.getBoundsInScreen(bounds); 647 return bounds.left == mHunLeft && bounds.right == mHunRight; 648 } 649 650 /** 651 * Returns whether the {@code window} is the main application window. A main application 652 * window is an application window on the default display that takes up the entire display. 653 */ isMainApplicationWindow(@onNull AccessibilityWindowInfo window)654 boolean isMainApplicationWindow(@NonNull AccessibilityWindowInfo window) { 655 Rect windowBounds = new Rect(); 656 window.getBoundsInScreen(windowBounds); 657 return window.getType() == TYPE_APPLICATION 658 && window.getDisplayId() == Display.DEFAULT_DISPLAY 659 && mAppWindowBounds.equals(windowBounds); 660 } 661 662 /** 663 * Searches from the given node up through its ancestors to the containing focus area, looking 664 * for a node that's marked as horizontally or vertically scrollable. Returns a copy of the 665 * first such node or null if none is found. The caller is responsible for recycling the result. 666 */ 667 @Nullable findScrollableContainer(@onNull AccessibilityNodeInfo node)668 AccessibilityNodeInfo findScrollableContainer(@NonNull AccessibilityNodeInfo node) { 669 return mTreeTraverser.findNodeOrAncestor(node, /* stopPredicate= */ Utils::isFocusArea, 670 /* targetPredicate= */ Utils::isScrollableContainer); 671 } 672 673 /** 674 * Returns the previous node before {@code referenceNode} in Tab order that can take focus or 675 * the next node after {@code referenceNode} in Tab order that can take focus, depending on 676 * {@code direction}. The search is limited to descendants of {@code containerNode}. Returns 677 * null if there are no descendants that can take focus in the given direction. The caller is 678 * responsible for recycling the result. 679 * 680 * @param containerNode the node with descendants 681 * @param referenceNode a descendant of {@code containerNode} to start from 682 * @param direction {@link View#FOCUS_FORWARD} or {@link View#FOCUS_BACKWARD} 683 * @return the node before or after {@code referenceNode} or null if none 684 */ 685 @Nullable findFocusableDescendantInDirection( @onNull AccessibilityNodeInfo containerNode, @NonNull AccessibilityNodeInfo referenceNode, int direction)686 AccessibilityNodeInfo findFocusableDescendantInDirection( 687 @NonNull AccessibilityNodeInfo containerNode, 688 @NonNull AccessibilityNodeInfo referenceNode, 689 int direction) { 690 AccessibilityNodeInfo targetNode = copyNode(referenceNode); 691 do { 692 AccessibilityNodeInfo nextTargetNode = targetNode.focusSearch(direction); 693 if (nextTargetNode == null 694 || nextTargetNode.equals(containerNode) 695 || !Utils.isDescendant(containerNode, nextTargetNode)) { 696 Utils.recycleNode(nextTargetNode); 697 Utils.recycleNode(targetNode); 698 return null; 699 } 700 if (nextTargetNode.equals(referenceNode) || nextTargetNode.equals(targetNode)) { 701 L.w((direction == View.FOCUS_FORWARD ? "Next" : "Previous") 702 + " node is the same node: " + referenceNode); 703 Utils.recycleNode(nextTargetNode); 704 Utils.recycleNode(targetNode); 705 return null; 706 } 707 targetNode.recycle(); 708 targetNode = nextTargetNode; 709 } while (!Utils.canTakeFocus(targetNode)); 710 return targetNode; 711 } 712 713 /** 714 * Returns the first descendant of {@code node} which can take focus. The nodes are searched in 715 * in depth-first order, not including {@code node} itself. If no descendant can take focus, 716 * null is returned. The caller is responsible for recycling the result. 717 */ 718 @Nullable findFirstFocusableDescendant(@onNull AccessibilityNodeInfo node)719 AccessibilityNodeInfo findFirstFocusableDescendant(@NonNull AccessibilityNodeInfo node) { 720 return mTreeTraverser.depthFirstSearch(node, 721 candidateNode -> candidateNode != node && Utils.canTakeFocus(candidateNode)); 722 } 723 724 /** 725 * Returns the last descendant of {@code node} which can take focus. The nodes are searched in 726 * reverse depth-first order, not including {@code node} itself. If no descendant can take 727 * focus, null is returned. The caller is responsible for recycling the result. 728 */ 729 @Nullable findLastFocusableDescendant(@onNull AccessibilityNodeInfo node)730 AccessibilityNodeInfo findLastFocusableDescendant(@NonNull AccessibilityNodeInfo node) { 731 return mTreeTraverser.reverseDepthFirstSearch(node, 732 candidateNode -> candidateNode != node && Utils.canTakeFocus(candidateNode)); 733 } 734 735 /** 736 * Scans descendants of the given {@code rootNode} looking for focus areas and adds them to the 737 * given list. It doesn't scan inside focus areas since nested focus areas aren't allowed. The 738 * caller is responsible for recycling added nodes. 739 * 740 * @param rootNode the root to start scanning from 741 * @param results a list of focus areas to add to 742 */ addFocusAreas(@onNull AccessibilityNodeInfo rootNode, @NonNull List<AccessibilityNodeInfo> results)743 private void addFocusAreas(@NonNull AccessibilityNodeInfo rootNode, 744 @NonNull List<AccessibilityNodeInfo> results) { 745 mTreeTraverser.depthFirstSelect(rootNode, Utils::isFocusArea, results); 746 } 747 748 /** 749 * Returns a copy of the best candidate from among the given {@code candidates} for a nudge 750 * from {@code sourceNode} in the given {@code direction}. Returns null if none of the {@code 751 * candidates} are in the given {@code direction}. The caller is responsible for recycling the 752 * result. 753 * 754 * @param candidates could be a list of {@link FocusArea}s, or a list of focusable views 755 */ 756 @Nullable chooseBestNudgeCandidate( @onNull AccessibilityNodeInfo sourceNode, @NonNull List<AccessibilityNodeInfo> candidates, int direction)757 private AccessibilityNodeInfo chooseBestNudgeCandidate( 758 @NonNull AccessibilityNodeInfo sourceNode, 759 @NonNull List<AccessibilityNodeInfo> candidates, 760 int direction) { 761 if (candidates.isEmpty()) { 762 return null; 763 } 764 Rect sourceBounds = Utils.getBoundsInScreen(sourceNode); 765 AccessibilityNodeInfo sourceFocusArea = getAncestorFocusArea(sourceNode); 766 Rect sourceFocusAreaBounds = Utils.getBoundsInScreen(sourceFocusArea); 767 sourceFocusArea.recycle(); 768 AccessibilityNodeInfo bestNode = null; 769 Rect bestBounds = new Rect(); 770 771 for (AccessibilityNodeInfo candidate : candidates) { 772 if (isCandidate(sourceBounds, sourceFocusAreaBounds, candidate, direction)) { 773 Rect candidateBounds = Utils.getBoundsInScreen(candidate); 774 if (bestNode == null || FocusFinder.isBetterCandidate( 775 direction, sourceBounds, candidateBounds, bestBounds)) { 776 bestNode = candidate; 777 bestBounds.set(candidateBounds); 778 } 779 } 780 } 781 return copyNode(bestNode); 782 } 783 784 /** 785 * Returns whether the given {@code node} is a candidate from {@code sourceBounds} to the given 786 * {@code direction}. 787 * <p> 788 * To be a candidate, the node 789 * <ul> 790 * <li>must be considered a candidate by {@link FocusFinder#isCandidate} if it represents a 791 * focusable view within a focus area 792 * <li>must be in the {@code direction} of the {@code sourceFocusAreaBounds} and one of its 793 * focusable descendants must be a candidate if it represents a focus area 794 * </ul> 795 */ isCandidate(@onNull Rect sourceBounds, @NonNull Rect sourceFocusAreaBounds, @NonNull AccessibilityNodeInfo node, int direction)796 private boolean isCandidate(@NonNull Rect sourceBounds, 797 @NonNull Rect sourceFocusAreaBounds, 798 @NonNull AccessibilityNodeInfo node, 799 int direction) { 800 AccessibilityNodeInfo candidate = mTreeTraverser.depthFirstSearch(node, 801 /* skipPredicate= */ candidateNode -> { 802 if (Utils.canTakeFocus(candidateNode)) { 803 return false; 804 } 805 // If a node can't take focus, it represents a focus area. If the focus area 806 // doesn't intersect with sourceFocusAreaBounds, and it's not in the given 807 // direction of sourceFocusAreaBounds, it's not a candidate, so we should return 808 // true to stop searching. 809 Rect candidateBounds = Utils.getBoundsInScreen(candidateNode); 810 return !Rect.intersects(candidateBounds,sourceFocusAreaBounds) 811 && !FocusFinder.isInDirection( 812 sourceFocusAreaBounds, candidateBounds, direction); 813 }, 814 /* targetPredicate= */ candidateNode -> { 815 // RotaryService can navigate to nodes in a WebView even when off-screen so we 816 // use canPerformFocus() to skip the bounds check. 817 if (isInWebView(candidateNode)) { 818 return Utils.canPerformFocus(candidateNode); 819 } 820 // If a node isn't visible to the user, e.g. another window is obscuring it, 821 // skip it. 822 if (!candidateNode.isVisibleToUser()) { 823 return false; 824 } 825 // If a node can't take focus, it represents a focus area, so we return false to 826 // skip the node and let it search its descendants. 827 if (!Utils.canTakeFocus(candidateNode)) { 828 return false; 829 } 830 // The node represents a focusable view in a focus area, so check the geometry. 831 Rect candidateBounds = Utils.getBoundsInScreen(candidateNode); 832 return FocusFinder.isCandidate(sourceBounds, candidateBounds, direction); 833 }); 834 if (candidate == null) { 835 return false; 836 } 837 candidate.recycle(); 838 return true; 839 } 840 copyNode(@ullable AccessibilityNodeInfo node)841 private AccessibilityNodeInfo copyNode(@Nullable AccessibilityNodeInfo node) { 842 return mNodeCopier.copy(node); 843 } 844 845 /** 846 * Returns the closest ancestor focus area of the given {@code node}. 847 * <ul> 848 * <li> If the given {@code node} is a {@link FocusArea} node or a descendant of a {@link 849 * FocusArea} node, returns the {@link FocusArea} node. 850 * <li> If there are no explicitly declared {@link FocusArea}s among the ancestors of this 851 * view, and this view is not in an embedded view hierarchy, returns the root node. 852 * <li> If there are no explicitly declared {@link FocusArea}s among the ancestors of this 853 * view, and this view is in an embedded view hierarchy, returns the root node of 854 * embedded view hierarchy. 855 * </ul> 856 * The caller is responsible for recycling the result. 857 */ 858 @NonNull getAncestorFocusArea(@onNull AccessibilityNodeInfo node)859 AccessibilityNodeInfo getAncestorFocusArea(@NonNull AccessibilityNodeInfo node) { 860 Predicate<AccessibilityNodeInfo> isFocusAreaOrRoot = candidateNode -> { 861 if (Utils.isFocusArea(candidateNode)) { 862 // The candidateNode is a focus area. 863 return true; 864 } 865 AccessibilityNodeInfo parent = candidateNode.getParent(); 866 if (parent == null) { 867 // The candidateNode is the root node. 868 return true; 869 } 870 if (Utils.isSurfaceView(parent)) { 871 // Treat the root of embedded view hierarchy (i.e., the only child of the 872 // SurfaceView) as an implicit focus area. 873 return true; 874 } 875 parent.recycle(); 876 return false; 877 }; 878 AccessibilityNodeInfo result = mTreeTraverser.findNodeOrAncestor(node, isFocusAreaOrRoot); 879 if (result == null || !Utils.isFocusArea(result)) { 880 L.w("Couldn't find ancestor focus area for given node: " + node); 881 } 882 return result; 883 } 884 885 /** 886 * Returns a copy of {@code node} or the nearest ancestor that represents a {@code WebView}. 887 * Returns null if {@code node} isn't a {@code WebView} and isn't a descendant of a {@code 888 * WebView}. 889 */ 890 @Nullable findWebViewAncestor(@onNull AccessibilityNodeInfo node)891 private AccessibilityNodeInfo findWebViewAncestor(@NonNull AccessibilityNodeInfo node) { 892 return mTreeTraverser.findNodeOrAncestor(node, Utils::isWebView); 893 } 894 895 /** Returns whether {@code node} is a {@code WebView} or is a descendant of one. */ isInWebView(@onNull AccessibilityNodeInfo node)896 boolean isInWebView(@NonNull AccessibilityNodeInfo node) { 897 AccessibilityNodeInfo webView = findWebViewAncestor(node); 898 if (webView == null) { 899 return false; 900 } 901 webView.recycle(); 902 return true; 903 } 904 905 /** 906 * Returns the next focusable node after {@code candidate} in {@code direction} in {@code 907 * webView} or null if none. This handles navigating into a WebView as well as within a WebView. 908 */ 909 @Nullable findNextFocusableInWebView(@onNull AccessibilityNodeInfo webView, @NonNull AccessibilityNodeInfo candidate, int direction)910 private AccessibilityNodeInfo findNextFocusableInWebView(@NonNull AccessibilityNodeInfo webView, 911 @NonNull AccessibilityNodeInfo candidate, int direction) { 912 // focusSearch() doesn't work in WebViews so use tree traversal instead. 913 if (Utils.isWebView(candidate)) { 914 if (direction == View.FOCUS_FORWARD) { 915 // When entering into a WebView, find the first focusable node within the 916 // WebView if any. 917 return findFirstFocusableDescendantInWebView(candidate); 918 } else { 919 // When backing into a WebView, find the last focusable node within the 920 // WebView if any. 921 return findLastFocusableDescendantInWebView(candidate); 922 } 923 } else { 924 // When navigating within a WebView, find the next or previous focusable node in 925 // depth-first order. 926 if (direction == View.FOCUS_FORWARD) { 927 return findFirstFocusDescendantInWebViewAfter(webView, candidate); 928 } else { 929 return findFirstFocusDescendantInWebViewBefore(webView, candidate); 930 } 931 } 932 } 933 934 /** 935 * Returns the first descendant of {@code webView} which can perform focus. This includes off- 936 * screen descendants. The nodes are searched in in depth-first order, not including 937 * {@code webView} itself. If no descendant can perform focus, null is returned. The caller is 938 * responsible for recycling the result. 939 */ 940 @Nullable findFirstFocusableDescendantInWebView( @onNull AccessibilityNodeInfo webView)941 private AccessibilityNodeInfo findFirstFocusableDescendantInWebView( 942 @NonNull AccessibilityNodeInfo webView) { 943 return mTreeTraverser.depthFirstSearch(webView, 944 candidateNode -> candidateNode != webView && Utils.canPerformFocus(candidateNode)); 945 } 946 947 /** 948 * Returns the last descendant of {@code webView} which can perform focus. This includes off- 949 * screen descendants. The nodes are searched in reverse depth-first order, not including 950 * {@code webView} itself. If no descendant can perform focus, null is returned. The caller is 951 * responsible for recycling the result. 952 */ 953 @Nullable findLastFocusableDescendantInWebView( @onNull AccessibilityNodeInfo webView)954 private AccessibilityNodeInfo findLastFocusableDescendantInWebView( 955 @NonNull AccessibilityNodeInfo webView) { 956 return mTreeTraverser.reverseDepthFirstSearch(webView, 957 candidateNode -> candidateNode != webView && Utils.canPerformFocus(candidateNode)); 958 } 959 960 @Nullable findFirstFocusDescendantInWebViewBefore( @onNull AccessibilityNodeInfo webView, @NonNull AccessibilityNodeInfo beforeNode)961 private AccessibilityNodeInfo findFirstFocusDescendantInWebViewBefore( 962 @NonNull AccessibilityNodeInfo webView, @NonNull AccessibilityNodeInfo beforeNode) { 963 boolean[] foundBeforeNode = new boolean[1]; 964 return mTreeTraverser.reverseDepthFirstSearch(webView, 965 node -> { 966 if (foundBeforeNode[0] && Utils.canPerformFocus(node)) { 967 return true; 968 } 969 if (node.equals(beforeNode)) { 970 foundBeforeNode[0] = true; 971 } 972 return false; 973 }); 974 } 975 976 @Nullable 977 private AccessibilityNodeInfo findFirstFocusDescendantInWebViewAfter( 978 @NonNull AccessibilityNodeInfo webView, @NonNull AccessibilityNodeInfo afterNode) { 979 boolean[] foundAfterNode = new boolean[1]; 980 return mTreeTraverser.depthFirstSearch(webView, 981 node -> { 982 if (foundAfterNode[0] && Utils.canPerformFocus(node)) { 983 return true; 984 } 985 if (node.equals(afterNode)) { 986 foundAfterNode[0] = true; 987 } 988 return false; 989 }); 990 } 991 992 @ExcludeFromCodeCoverageGeneratedReport(reason = DUMP_INFO) 993 void dump(@NonNull DualDumpOutputStream dumpOutputStream, boolean dumpAsProto, 994 @NonNull String fieldName, long fieldId) { 995 long fieldToken = dumpOutputStream.start(fieldName, fieldId); 996 dumpOutputStream.write("hunLeft", RotaryProtos.Navigator.HUN_LEFT, mHunLeft); 997 dumpOutputStream.write("hunRight", RotaryProtos.Navigator.HUN_RIGHT, mHunRight); 998 DumpUtils.writeFocusDirection(dumpOutputStream, dumpAsProto, "hunNudgeDirection", 999 RotaryProtos.Navigator.HUN_NUDGE_DIRECTION, mHunNudgeDirection); 1000 DumpUtils.writeRect(dumpOutputStream, mAppWindowBounds, "appWindowBounds", 1001 RotaryProtos.Navigator.APP_WINDOW_BOUNDS); 1002 mSurfaceViewHelper.dump(dumpOutputStream, dumpAsProto, "surfaceViewHelper", 1003 RotaryProtos.Navigator.SURFACE_VIEW_HELPER); 1004 dumpOutputStream.end(fieldToken); 1005 } 1006 1007 @ExcludeFromCodeCoverageGeneratedReport(reason = BOILERPLATE_CODE) 1008 static String directionToString(@View.FocusRealDirection int direction) { 1009 switch (direction) { 1010 case FOCUS_UP: 1011 return "FOCUS_UP"; 1012 case FOCUS_DOWN: 1013 return "FOCUS_DOWN"; 1014 case FOCUS_LEFT: 1015 return "FOCUS_LEFT"; 1016 case FOCUS_RIGHT: 1017 return "FOCUS_RIGHT"; 1018 default: 1019 return "<unknown direction " + direction + ">"; 1020 } 1021 } 1022 1023 /** Result from {@link #findRotateTarget}. */ 1024 static class FindRotateTargetResult { 1025 @NonNull final AccessibilityNodeInfo node; 1026 final int advancedCount; 1027 1028 FindRotateTargetResult(@NonNull AccessibilityNodeInfo node, int advancedCount) { 1029 this.node = node; 1030 this.advancedCount = advancedCount; 1031 } 1032 } 1033 } 1034