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