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