1 /* 2 * Copyright 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 17 package com.android.car.rotary; 18 19 import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT; 20 21 import static com.android.car.ui.utils.RotaryConstants.BOTTOM_BOUND_OFFSET_FOR_NUDGE; 22 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_BOTTOM_BOUND_OFFSET; 23 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_LEFT_BOUND_OFFSET; 24 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_RIGHT_BOUND_OFFSET; 25 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_TOP_BOUND_OFFSET; 26 import static com.android.car.ui.utils.RotaryConstants.LEFT_BOUND_OFFSET_FOR_NUDGE; 27 import static com.android.car.ui.utils.RotaryConstants.RIGHT_BOUND_OFFSET_FOR_NUDGE; 28 import static com.android.car.ui.utils.RotaryConstants.ROTARY_CONTAINER; 29 import static com.android.car.ui.utils.RotaryConstants.ROTARY_HORIZONTALLY_SCROLLABLE; 30 import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE; 31 import static com.android.car.ui.utils.RotaryConstants.TOP_BOUND_OFFSET_FOR_NUDGE; 32 33 import android.content.ComponentName; 34 import android.graphics.Rect; 35 import android.os.Bundle; 36 import android.text.TextUtils; 37 import android.view.SurfaceView; 38 import android.view.accessibility.AccessibilityNodeInfo; 39 import android.view.accessibility.AccessibilityWindowInfo; 40 import android.view.inputmethod.InputMethodInfo; 41 import android.view.inputmethod.InputMethodManager; 42 import android.webkit.WebView; 43 44 import androidx.annotation.NonNull; 45 import androidx.annotation.Nullable; 46 import androidx.annotation.VisibleForTesting; 47 48 import com.android.car.ui.FocusArea; 49 import com.android.car.ui.FocusParkingView; 50 51 import java.util.List; 52 53 /** 54 * Utility methods for {@link AccessibilityNodeInfo} and {@link AccessibilityWindowInfo}. 55 * <p> 56 * Because {@link AccessibilityNodeInfo}s must be recycled, it's important to be consistent about 57 * who is responsible for recycling them. For simplicity, it's best to avoid having multiple objects 58 * refer to the same instance of {@link AccessibilityNodeInfo}. Instead, each object should keep its 59 * own copy which it's responsible for. Methods that return an {@link AccessibilityNodeInfo} 60 * generally pass ownership to the caller. Such methods should never return a reference to one of 61 * their parameters or the caller will recycle it twice. 62 */ 63 final class Utils { 64 65 @VisibleForTesting 66 static final String FOCUS_AREA_CLASS_NAME = FocusArea.class.getName(); 67 @VisibleForTesting 68 static final String FOCUS_PARKING_VIEW_CLASS_NAME = FocusParkingView.class.getName(); 69 @VisibleForTesting 70 static final String GENERIC_FOCUS_PARKING_VIEW_CLASS_NAME = 71 "com.android.car.rotary.FocusParkingView"; 72 73 @VisibleForTesting 74 static final String WEB_VIEW_CLASS_NAME = WebView.class.getName(); 75 @VisibleForTesting 76 static final String COMPOSE_VIEW_CLASS_NAME = "androidx.compose.ui.platform.ComposeView"; 77 @VisibleForTesting 78 static final String SURFACE_VIEW_CLASS_NAME = SurfaceView.class.getName(); 79 static final String LOG_INDENT = " "; 80 81 private static final int FIND_FOCUS_MAX_TRY_COUNT = 3; 82 Utils()83 private Utils() { 84 } 85 86 /** Recycles a node. */ recycleNode(@ullable AccessibilityNodeInfo node)87 static void recycleNode(@Nullable AccessibilityNodeInfo node) { 88 if (node != null) { 89 node.recycle(); 90 } 91 } 92 93 /** Recycles all specified nodes. */ recycleNodes(AccessibilityNodeInfo... nodes)94 static void recycleNodes(AccessibilityNodeInfo... nodes) { 95 for (AccessibilityNodeInfo node : nodes) { 96 recycleNode(node); 97 } 98 } 99 100 /** Recycles a list of nodes. */ recycleNodes(@ullable List<AccessibilityNodeInfo> nodes)101 static void recycleNodes(@Nullable List<AccessibilityNodeInfo> nodes) { 102 if (nodes != null) { 103 for (AccessibilityNodeInfo node : nodes) { 104 recycleNode(node); 105 } 106 } 107 } 108 109 /** 110 * Updates the given {@code node} in case the view represented by it is no longer in the view 111 * tree. If it's still in the view tree, returns the {@code node}. Otherwise recycles the 112 * {@code node} and returns null. 113 */ refreshNode(@ullable AccessibilityNodeInfo node)114 static AccessibilityNodeInfo refreshNode(@Nullable AccessibilityNodeInfo node) { 115 if (node == null) { 116 return null; 117 } 118 boolean succeeded = node.refresh(); 119 if (succeeded) { 120 return node; 121 } 122 L.w("This node is no longer in the view tree: " + node); 123 node.recycle(); 124 return null; 125 } 126 127 /** 128 * Returns whether RotaryService can call {@code performFocusAction()} with the given 129 * {@code node}. 130 * <p> 131 * We don't check if the node is visible because we want to allow nodes scrolled off the screen 132 * to be focused. 133 */ canPerformFocus(@onNull AccessibilityNodeInfo node)134 static boolean canPerformFocus(@NonNull AccessibilityNodeInfo node) { 135 if (!node.isFocusable() || !node.isEnabled()) { 136 return false; 137 } 138 139 // ACTION_FOCUS doesn't work on WebViews. 140 if (isWebView(node)) { 141 return false; 142 } 143 144 // SurfaceView in the client app shouldn't be focused by the rotary controller. See 145 // SurfaceViewHelper for more context. 146 if (isSurfaceView(node)) { 147 return false; 148 } 149 150 // Check the bounds in the parent rather than the bounds in the screen because the latter 151 // are always empty for views that are off screen. 152 Rect bounds = new Rect(); 153 node.getBoundsInParent(bounds); 154 if (bounds.isEmpty()) { 155 // Some nodes, such as those in ComposeView hierarchies may not set bounds in parents, 156 // since the APIs are deprecated. So, check bounds in screen just in case. 157 node.getBoundsInScreen(bounds); 158 } 159 return !bounds.isEmpty(); 160 } 161 162 /** 163 * Returns whether the given {@code node} can be focused by the rotary controller. 164 * <ul> 165 * <li>To be a focus candidate, a node must be able to perform focus action. 166 * <li>A {@link FocusParkingView} is not a focus candidate. 167 * <li>A scrollable container is a focus candidate if it meets certain conditions. 168 * <li>To be a focus candidate, a node must be on the screen. Usually the node off the 169 * screen (its bounds in screen is empty) is ignored by RotaryService, but there are 170 * exceptions, e.g. nodes in a WebView. 171 * </ul> 172 */ canTakeFocus(@onNull AccessibilityNodeInfo node)173 static boolean canTakeFocus(@NonNull AccessibilityNodeInfo node) { 174 boolean result = canPerformFocus(node) 175 && !isFocusParkingView(node) 176 && (!isScrollableContainer(node) || canScrollableContainerTakeFocus(node)); 177 if (result) { 178 Rect bounds = getBoundsInScreen(node); 179 if (!bounds.isEmpty()) { 180 return true; 181 } 182 L.d("node is off the screen but it's not ignored by RotaryService: " + node); 183 } 184 return false; 185 } 186 187 /** 188 * Returns whether the given {@code scrollableContainer} can be focused by the rotary 189 * controller. 190 * <p> 191 * A scrollable container can take focus if it should scroll (i.e., is scrollable and has no 192 * focusable descendants on screen). A container is skipped so that its element can take focus. 193 * A container is not skipped so that it can be focused and scrolled when the rotary controller 194 * is rotated. 195 */ canScrollableContainerTakeFocus( @onNull AccessibilityNodeInfo scrollableContainer)196 static boolean canScrollableContainerTakeFocus( 197 @NonNull AccessibilityNodeInfo scrollableContainer) { 198 return scrollableContainer.isScrollable() && !descendantCanTakeFocus(scrollableContainer); 199 } 200 201 /** Returns whether the given {@code node} or its descendants can take focus. */ canHaveFocus(@onNull AccessibilityNodeInfo node)202 static boolean canHaveFocus(@NonNull AccessibilityNodeInfo node) { 203 return canTakeFocus(node) || descendantCanTakeFocus(node); 204 } 205 206 /** Returns whether the given {@code node}'s descendants can take focus. */ descendantCanTakeFocus(@onNull AccessibilityNodeInfo node)207 static boolean descendantCanTakeFocus(@NonNull AccessibilityNodeInfo node) { 208 for (int i = 0; i < node.getChildCount(); i++) { 209 AccessibilityNodeInfo childNode = node.getChild(i); 210 if (childNode != null) { 211 boolean result = canHaveFocus(childNode); 212 childNode.recycle(); 213 if (result) { 214 return true; 215 } 216 } 217 } 218 return false; 219 } 220 221 /** 222 * Searches {@code node} and its descendants for the focused node. Returns whether the focus 223 * was found. 224 */ hasFocus(@onNull AccessibilityNodeInfo node)225 static boolean hasFocus(@NonNull AccessibilityNodeInfo node) { 226 AccessibilityNodeInfo foundFocus = node.findFocus(FOCUS_INPUT); 227 if (foundFocus == null) { 228 L.d("Failed to find focused node in " + node); 229 return false; 230 } 231 L.d("Found focused node " + foundFocus); 232 foundFocus.recycle(); 233 return true; 234 } 235 236 /** 237 * Returns whether the given {@code node} represents a car ui lib {@link FocusParkingView} or a 238 * generic FocusParkingView. 239 */ isFocusParkingView(@onNull AccessibilityNodeInfo node)240 static boolean isFocusParkingView(@NonNull AccessibilityNodeInfo node) { 241 return isCarUiFocusParkingView(node) || isGenericFocusParkingView(node); 242 } 243 244 /** Returns whether the given {@code node} represents a car ui lib {@link FocusParkingView}. */ isCarUiFocusParkingView(@onNull AccessibilityNodeInfo node)245 static boolean isCarUiFocusParkingView(@NonNull AccessibilityNodeInfo node) { 246 CharSequence className = node.getClassName(); 247 return className != null && FOCUS_PARKING_VIEW_CLASS_NAME.contentEquals(className); 248 } 249 250 /** 251 * Returns whether the given {@code node} represents a generic FocusParkingView (primarily used 252 * as a fallback for potential apps that are not using Chassis). 253 */ isGenericFocusParkingView(@onNull AccessibilityNodeInfo node)254 static boolean isGenericFocusParkingView(@NonNull AccessibilityNodeInfo node) { 255 CharSequence className = node.getClassName(); 256 return className != null && GENERIC_FOCUS_PARKING_VIEW_CLASS_NAME.contentEquals(className); 257 } 258 259 /** Returns whether the given {@code node} represents a {@link FocusArea}. */ isFocusArea(@onNull AccessibilityNodeInfo node)260 static boolean isFocusArea(@NonNull AccessibilityNodeInfo node) { 261 CharSequence className = node.getClassName(); 262 return className != null && FOCUS_AREA_CLASS_NAME.contentEquals(className); 263 } 264 265 /** 266 * Returns whether {@code node} represents a {@code WebView} or the root of the document within 267 * one. 268 * <p> 269 * The descendants of a node representing a {@code WebView} represent HTML elements rather 270 * than {@code View}s so {@link AccessibilityNodeInfo#focusSearch} doesn't work for these nodes. 271 * The focused state of these nodes isn't reliable. The node representing a {@code WebView} has 272 * a single child node representing the HTML document. This node also claims to be a {@code 273 * WebView}. Unlike its parent, it is scrollable and focusable. 274 */ isWebView(@onNull AccessibilityNodeInfo node)275 static boolean isWebView(@NonNull AccessibilityNodeInfo node) { 276 CharSequence className = node.getClassName(); 277 return className != null && WEB_VIEW_CLASS_NAME.contentEquals(className); 278 } 279 280 /** 281 * Returns whether {@code node} represents a {@code ComposeView}. 282 * <p> 283 * The descendants of a node representing a {@code ComposeView} represent "Composables" rather 284 * than {@link android.view.View}s so {@link AccessibilityNodeInfo#focusSearch} currently does 285 * not work for these nodes. The outcome of b/192274274 could change this. 286 * 287 * TODO(b/192274274): This method is only necessary until {@code ComposeView} supports 288 * {@link AccessibilityNodeInfo#focusSearch(int)}. 289 */ isComposeView(@onNull AccessibilityNodeInfo node)290 static boolean isComposeView(@NonNull AccessibilityNodeInfo node) { 291 CharSequence className = node.getClassName(); 292 return className != null && COMPOSE_VIEW_CLASS_NAME.contentEquals(className); 293 } 294 295 /** Returns whether the given {@code node} represents a {@link SurfaceView}. */ isSurfaceView(@onNull AccessibilityNodeInfo node)296 static boolean isSurfaceView(@NonNull AccessibilityNodeInfo node) { 297 CharSequence className = node.getClassName(); 298 return className != null && SURFACE_VIEW_CLASS_NAME.contentEquals(className); 299 } 300 301 /** 302 * Returns whether the given node represents a rotary container, as indicated by its content 303 * description. This includes containers that can be scrolled using the rotary controller as 304 * well as other containers." 305 */ isRotaryContainer(@onNull AccessibilityNodeInfo node)306 static boolean isRotaryContainer(@NonNull AccessibilityNodeInfo node) { 307 CharSequence contentDescription = node.getContentDescription(); 308 return contentDescription != null 309 && (ROTARY_CONTAINER.contentEquals(contentDescription) 310 || ROTARY_HORIZONTALLY_SCROLLABLE.contentEquals(contentDescription) 311 || ROTARY_VERTICALLY_SCROLLABLE.contentEquals(contentDescription)); 312 } 313 314 /** 315 * Returns whether the given node represents a view which can be scrolled using the rotary 316 * controller, as indicated by its content description. 317 */ isScrollableContainer(@onNull AccessibilityNodeInfo node)318 static boolean isScrollableContainer(@NonNull AccessibilityNodeInfo node) { 319 CharSequence contentDescription = node.getContentDescription(); 320 return contentDescription != null 321 && (ROTARY_HORIZONTALLY_SCROLLABLE.contentEquals(contentDescription) 322 || ROTARY_VERTICALLY_SCROLLABLE.contentEquals(contentDescription)); 323 } 324 325 /** 326 * Returns whether the given node represents a view which can be scrolled horizontally using the 327 * rotary controller, as indicated by its content description. 328 */ isHorizontallyScrollableContainer(@onNull AccessibilityNodeInfo node)329 static boolean isHorizontallyScrollableContainer(@NonNull AccessibilityNodeInfo node) { 330 CharSequence contentDescription = node.getContentDescription(); 331 return contentDescription != null 332 && (ROTARY_HORIZONTALLY_SCROLLABLE.contentEquals(contentDescription)); 333 } 334 335 /** Returns whether {@code descendant} is a descendant of {@code ancestor}. */ isDescendant(@onNull AccessibilityNodeInfo ancestor, @NonNull AccessibilityNodeInfo descendant)336 static boolean isDescendant(@NonNull AccessibilityNodeInfo ancestor, 337 @NonNull AccessibilityNodeInfo descendant) { 338 AccessibilityNodeInfo parent = descendant.getParent(); 339 if (parent == null) { 340 return false; 341 } 342 boolean result = parent.equals(ancestor) || isDescendant(ancestor, parent); 343 recycleNode(parent); 344 return result; 345 } 346 347 /** Recycles a window. */ recycleWindow(@ullable AccessibilityWindowInfo window)348 static void recycleWindow(@Nullable AccessibilityWindowInfo window) { 349 if (window != null) { 350 window.recycle(); 351 } 352 } 353 354 /** Recycles a list of windows. */ recycleWindows(@ullable List<AccessibilityWindowInfo> windows)355 static void recycleWindows(@Nullable List<AccessibilityWindowInfo> windows) { 356 if (windows != null) { 357 for (AccessibilityWindowInfo window : windows) { 358 recycleWindow(window); 359 } 360 } 361 } 362 363 /** 364 * Returns a reference to the window with ID {@code windowId} or null if not found. 365 * <p> 366 * <strong>Note:</strong> Do not recycle the result. 367 */ 368 @Nullable findWindowWithId(@onNull List<AccessibilityWindowInfo> windows, int windowId)369 static AccessibilityWindowInfo findWindowWithId(@NonNull List<AccessibilityWindowInfo> windows, 370 int windowId) { 371 for (AccessibilityWindowInfo window : windows) { 372 if (window.getId() == windowId) { 373 return window; 374 } 375 } 376 return null; 377 } 378 379 /** Gets the bounds in screen of the given {@code node}. */ 380 @NonNull getBoundsInScreen(@onNull AccessibilityNodeInfo node)381 static Rect getBoundsInScreen(@NonNull AccessibilityNodeInfo node) { 382 Rect bounds = new Rect(); 383 node.getBoundsInScreen(bounds); 384 if (Utils.isFocusArea(node)) { 385 // For a FocusArea, the bounds used for finding the nudge target are its View bounds 386 // minus the offset. 387 Bundle bundle = node.getExtras(); 388 bounds.left += bundle.getInt(FOCUS_AREA_LEFT_BOUND_OFFSET); 389 bounds.right -= bundle.getInt(FOCUS_AREA_RIGHT_BOUND_OFFSET); 390 bounds.top += bundle.getInt(FOCUS_AREA_TOP_BOUND_OFFSET); 391 bounds.bottom -= bundle.getInt(FOCUS_AREA_BOTTOM_BOUND_OFFSET); 392 } else if (node.hasExtras()) { 393 // For a view that overrides nudge bounds, the bounds used for finding the nudge target 394 // are its View bounds plus/minus the offset. 395 Bundle bundle = node.getExtras(); 396 bounds.left += bundle.getInt(LEFT_BOUND_OFFSET_FOR_NUDGE); 397 bounds.right -= bundle.getInt(RIGHT_BOUND_OFFSET_FOR_NUDGE); 398 bounds.top += bundle.getInt(TOP_BOUND_OFFSET_FOR_NUDGE); 399 bounds.bottom -= bundle.getInt(BOTTOM_BOUND_OFFSET_FOR_NUDGE); 400 } else if (Utils.isRotaryContainer(node)) { 401 // For a rotary container, the bounds used for finding the nudge target are the 402 // intersection of the two bounds: (1) minimum bounds containing its children, and 403 // (2) its ancestor FocusArea's bounds, if any. 404 bounds.setEmpty(); 405 Rect childBounds = new Rect(); 406 for (int i = 0; i < node.getChildCount(); i++) { 407 AccessibilityNodeInfo child = node.getChild(i); 408 if (child != null) { 409 child.getBoundsInScreen(childBounds); 410 child.recycle(); 411 bounds.union(childBounds); 412 } 413 } 414 AccessibilityNodeInfo focusArea = getAncestorFocusArea(node); 415 if (focusArea != null) { 416 Rect focusAreaBounds = getBoundsInScreen(focusArea); 417 bounds.setIntersect(bounds, focusAreaBounds); 418 focusArea.recycle(); 419 } 420 } 421 return bounds; 422 } 423 424 @Nullable getAncestorFocusArea(@onNull AccessibilityNodeInfo node)425 private static AccessibilityNodeInfo getAncestorFocusArea(@NonNull AccessibilityNodeInfo node) { 426 AccessibilityNodeInfo ancestor = node.getParent(); 427 while (ancestor != null) { 428 if (isFocusArea(ancestor)) { 429 return ancestor; 430 } 431 AccessibilityNodeInfo nextAncestor = ancestor.getParent(); 432 ancestor.recycle(); 433 ancestor = nextAncestor; 434 } 435 return null; 436 } 437 438 /** Checks if the {@code componentName} is an installed input method. */ isInstalledIme(@ullable String componentName, @NonNull InputMethodManager imm)439 static boolean isInstalledIme(@Nullable String componentName, 440 @NonNull InputMethodManager imm) { 441 if (TextUtils.isEmpty(componentName)) { 442 return false; 443 } 444 // Use getInputMethodList() to get the installed input methods. Don't do that by fetching 445 // ENABLED_INPUT_METHODS and DISABLED_SYSTEM_INPUT_METHODS from the secure setting, 446 // because RotaryIME may not be included in any of them (b/229144904). 447 ComponentName component = ComponentName.unflattenFromString(componentName); 448 List<InputMethodInfo> imeList = imm.getInputMethodList(); 449 for (InputMethodInfo ime : imeList) { 450 ComponentName imeComponent = ime.getComponent(); 451 if (component.equals(imeComponent)) { 452 return true; 453 } 454 } 455 return false; 456 } 457 458 /** Retries several times to find the focused node. See b/301318227. */ 459 @Nullable findFocusWithRetry(@onNull AccessibilityNodeInfo root)460 static AccessibilityNodeInfo findFocusWithRetry(@NonNull AccessibilityNodeInfo root) { 461 AccessibilityNodeInfo focusedNode; 462 for (int i = 0; i < FIND_FOCUS_MAX_TRY_COUNT; i++) { 463 focusedNode = root.findFocus(FOCUS_INPUT); 464 L.v("findFocus():" + focusedNode); 465 focusedNode = Utils.refreshNode(focusedNode); 466 if (focusedNode != null && focusedNode.isFocused()) { 467 return focusedNode; 468 } 469 Utils.recycleNode(focusedNode); 470 L.w("Retry findFocus()"); 471 } 472 L.e("Failed to find focused node in " + root); 473 return null; 474 } 475 476 /** Prints the node and its descendants. */ printDescendants(@ullable AccessibilityNodeInfo root, String indent)477 static void printDescendants(@Nullable AccessibilityNodeInfo root, String indent) { 478 if (root == null) { 479 return; 480 } 481 L.d(indent + root); 482 for (int i = 0; i < root.getChildCount(); i++) { 483 AccessibilityNodeInfo child = root.getChild(i); 484 printDescendants(child, indent + LOG_INDENT); 485 Utils.recycleNode(child); 486 } 487 } 488 } 489