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