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