• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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