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