• 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.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