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