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