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.content.ComponentName;
34 import android.graphics.Rect;
35 import android.os.Bundle;
36 import android.text.TextUtils;
37 import android.view.SurfaceView;
38 import android.view.accessibility.AccessibilityNodeInfo;
39 import android.view.accessibility.AccessibilityWindowInfo;
40 import android.view.inputmethod.InputMethodInfo;
41 import android.view.inputmethod.InputMethodManager;
42 import android.webkit.WebView;
43 
44 import androidx.annotation.NonNull;
45 import androidx.annotation.Nullable;
46 import androidx.annotation.VisibleForTesting;
47 
48 import com.android.car.ui.FocusArea;
49 import com.android.car.ui.FocusParkingView;
50 
51 import java.util.List;
52 
53 /**
54  * Utility methods for {@link AccessibilityNodeInfo} and {@link AccessibilityWindowInfo}.
55  * <p>
56  * Because {@link AccessibilityNodeInfo}s must be recycled, it's important to be consistent about
57  * who is responsible for recycling them. For simplicity, it's best to avoid having multiple objects
58  * refer to the same instance of {@link AccessibilityNodeInfo}. Instead, each object should keep its
59  * own copy which it's responsible for. Methods that return an {@link AccessibilityNodeInfo}
60  * generally pass ownership to the caller. Such methods should never return a reference to one of
61  * their parameters or the caller will recycle it twice.
62  */
63 final class Utils {
64 
65     @VisibleForTesting
66     static final String FOCUS_AREA_CLASS_NAME = FocusArea.class.getName();
67     @VisibleForTesting
68     static final String FOCUS_PARKING_VIEW_CLASS_NAME = FocusParkingView.class.getName();
69     @VisibleForTesting
70     static final String GENERIC_FOCUS_PARKING_VIEW_CLASS_NAME =
71             "com.android.car.rotary.FocusParkingView";
72 
73     @VisibleForTesting
74     static final String WEB_VIEW_CLASS_NAME = WebView.class.getName();
75     @VisibleForTesting
76     static final String COMPOSE_VIEW_CLASS_NAME = "androidx.compose.ui.platform.ComposeView";
77     @VisibleForTesting
78     static final String SURFACE_VIEW_CLASS_NAME = SurfaceView.class.getName();
79     static final String LOG_INDENT = "    ";
80 
81     private static final int FIND_FOCUS_MAX_TRY_COUNT = 3;
82 
Utils()83     private Utils() {
84     }
85 
86     /** Recycles a node. */
recycleNode(@ullable AccessibilityNodeInfo node)87     static void recycleNode(@Nullable AccessibilityNodeInfo node) {
88         if (node != null) {
89             node.recycle();
90         }
91     }
92 
93     /** Recycles all specified nodes. */
recycleNodes(AccessibilityNodeInfo... nodes)94     static void recycleNodes(AccessibilityNodeInfo... nodes) {
95         for (AccessibilityNodeInfo node : nodes) {
96             recycleNode(node);
97         }
98     }
99 
100     /** Recycles a list of nodes. */
recycleNodes(@ullable List<AccessibilityNodeInfo> nodes)101     static void recycleNodes(@Nullable List<AccessibilityNodeInfo> nodes) {
102         if (nodes != null) {
103             for (AccessibilityNodeInfo node : nodes) {
104                 recycleNode(node);
105             }
106         }
107     }
108 
109     /**
110      * Updates the given {@code node} in case the view represented by it is no longer in the view
111      * tree. If it's still in the view tree, returns the {@code node}. Otherwise recycles the
112      * {@code node} and returns null.
113      */
refreshNode(@ullable AccessibilityNodeInfo node)114     static AccessibilityNodeInfo refreshNode(@Nullable AccessibilityNodeInfo node) {
115         if (node == null) {
116             return null;
117         }
118         boolean succeeded = node.refresh();
119         if (succeeded) {
120             return node;
121         }
122         L.w("This node is no longer in the view tree: " + node);
123         node.recycle();
124         return null;
125     }
126 
127     /**
128      * Returns whether RotaryService can call {@code performFocusAction()} with the given
129      * {@code node}.
130      * <p>
131      * We don't check if the node is visible because we want to allow nodes scrolled off the screen
132      * to be focused.
133      */
canPerformFocus(@onNull AccessibilityNodeInfo node)134     static boolean canPerformFocus(@NonNull AccessibilityNodeInfo node) {
135         if (!node.isFocusable() || !node.isEnabled()) {
136             return false;
137         }
138 
139         // ACTION_FOCUS doesn't work on WebViews.
140         if (isWebView(node)) {
141             return false;
142         }
143 
144         // SurfaceView in the client app shouldn't be focused by the rotary controller. See
145         // SurfaceViewHelper for more context.
146         if (isSurfaceView(node)) {
147             return false;
148         }
149 
150         // Check the bounds in the parent rather than the bounds in the screen because the latter
151         // are always empty for views that are off screen.
152         Rect bounds = new Rect();
153         node.getBoundsInParent(bounds);
154         if (bounds.isEmpty()) {
155             // Some nodes, such as those in ComposeView hierarchies may not set bounds in parents,
156             // since the APIs are deprecated. So, check bounds in screen just in case.
157             node.getBoundsInScreen(bounds);
158         }
159         return !bounds.isEmpty();
160     }
161 
162     /**
163      * Returns whether the given {@code node} can be focused by the rotary controller.
164      * <ul>
165      *     <li>To be a focus candidate, a node must be able to perform focus action.
166      *     <li>A {@link FocusParkingView} is not a focus candidate.
167      *     <li>A scrollable container is a focus candidate if it meets certain conditions.
168      *     <li>To be a focus candidate, a node must be on the screen. Usually the node off the
169      *         screen (its bounds in screen is empty) is ignored by RotaryService, but there are
170      *         exceptions, e.g. nodes in a WebView.
171      * </ul>
172      */
canTakeFocus(@onNull AccessibilityNodeInfo node)173     static boolean canTakeFocus(@NonNull AccessibilityNodeInfo node) {
174         boolean result = canPerformFocus(node)
175                 && !isFocusParkingView(node)
176                 && (!isScrollableContainer(node) || canScrollableContainerTakeFocus(node));
177         if (result) {
178             Rect bounds = getBoundsInScreen(node);
179             if (!bounds.isEmpty()) {
180                 return true;
181             }
182             L.d("node is off the screen but it's not ignored by RotaryService: " + node);
183         }
184         return false;
185     }
186 
187     /**
188      * Returns whether the given {@code scrollableContainer} can be focused by the rotary
189      * controller.
190      * <p>
191      * A scrollable container can take focus if it should scroll (i.e., is scrollable and has no
192      * focusable descendants on screen). A container is skipped so that its element can take focus.
193      * A container is not skipped so that it can be focused and scrolled when the rotary controller
194      * is rotated.
195      */
canScrollableContainerTakeFocus( @onNull AccessibilityNodeInfo scrollableContainer)196     static boolean canScrollableContainerTakeFocus(
197             @NonNull AccessibilityNodeInfo scrollableContainer) {
198         return scrollableContainer.isScrollable() && !descendantCanTakeFocus(scrollableContainer);
199     }
200 
201     /** Returns whether the given {@code node} or its descendants can take focus. */
canHaveFocus(@onNull AccessibilityNodeInfo node)202     static boolean canHaveFocus(@NonNull AccessibilityNodeInfo node) {
203         return canTakeFocus(node) || descendantCanTakeFocus(node);
204     }
205 
206     /** Returns whether the given {@code node}'s descendants can take focus. */
descendantCanTakeFocus(@onNull AccessibilityNodeInfo node)207     static boolean descendantCanTakeFocus(@NonNull AccessibilityNodeInfo node) {
208         for (int i = 0; i < node.getChildCount(); i++) {
209             AccessibilityNodeInfo childNode = node.getChild(i);
210             if (childNode != null) {
211                 boolean result = canHaveFocus(childNode);
212                 childNode.recycle();
213                 if (result) {
214                     return true;
215                 }
216             }
217         }
218         return false;
219     }
220 
221     /**
222      * Searches {@code node} and its descendants for the focused node. Returns whether the focus
223      * was found.
224      */
hasFocus(@onNull AccessibilityNodeInfo node)225     static boolean hasFocus(@NonNull AccessibilityNodeInfo node) {
226         AccessibilityNodeInfo foundFocus = node.findFocus(FOCUS_INPUT);
227         if (foundFocus == null) {
228             L.d("Failed to find focused node in " + node);
229             return false;
230         }
231         L.d("Found focused node " + foundFocus);
232         foundFocus.recycle();
233         return true;
234     }
235 
236     /**
237      * Returns whether the given {@code node} represents a car ui lib {@link FocusParkingView} or a
238      * generic FocusParkingView.
239      */
isFocusParkingView(@onNull AccessibilityNodeInfo node)240     static boolean isFocusParkingView(@NonNull AccessibilityNodeInfo node) {
241         return isCarUiFocusParkingView(node) || isGenericFocusParkingView(node);
242     }
243 
244     /** Returns whether the given {@code node} represents a car ui lib {@link FocusParkingView}. */
isCarUiFocusParkingView(@onNull AccessibilityNodeInfo node)245     static boolean isCarUiFocusParkingView(@NonNull AccessibilityNodeInfo node) {
246         CharSequence className = node.getClassName();
247         return className != null && FOCUS_PARKING_VIEW_CLASS_NAME.contentEquals(className);
248     }
249 
250     /**
251      * Returns whether the given {@code node} represents a generic FocusParkingView (primarily used
252      * as a fallback for potential apps that are not using Chassis).
253      */
isGenericFocusParkingView(@onNull AccessibilityNodeInfo node)254     static boolean isGenericFocusParkingView(@NonNull AccessibilityNodeInfo node) {
255         CharSequence className = node.getClassName();
256         return className != null && GENERIC_FOCUS_PARKING_VIEW_CLASS_NAME.contentEquals(className);
257     }
258 
259     /** Returns whether the given {@code node} represents a {@link FocusArea}. */
isFocusArea(@onNull AccessibilityNodeInfo node)260     static boolean isFocusArea(@NonNull AccessibilityNodeInfo node) {
261         CharSequence className = node.getClassName();
262         return className != null && FOCUS_AREA_CLASS_NAME.contentEquals(className);
263     }
264 
265     /**
266      * Returns whether {@code node} represents a {@code WebView} or the root of the document within
267      * one.
268      * <p>
269      * The descendants of a node representing a {@code WebView} represent HTML elements rather
270      * than {@code View}s so {@link AccessibilityNodeInfo#focusSearch} doesn't work for these nodes.
271      * The focused state of these nodes isn't reliable. The node representing a {@code WebView} has
272      * a single child node representing the HTML document. This node also claims to be a {@code
273      * WebView}. Unlike its parent, it is scrollable and focusable.
274      */
isWebView(@onNull AccessibilityNodeInfo node)275     static boolean isWebView(@NonNull AccessibilityNodeInfo node) {
276         CharSequence className = node.getClassName();
277         return className != null && WEB_VIEW_CLASS_NAME.contentEquals(className);
278     }
279 
280     /**
281      * Returns whether {@code node} represents a {@code ComposeView}.
282      * <p>
283      * The descendants of a node representing a {@code ComposeView} represent "Composables" rather
284      * than {@link android.view.View}s so {@link AccessibilityNodeInfo#focusSearch} currently does
285      * not work for these nodes. The outcome of b/192274274 could change this.
286      *
287      * TODO(b/192274274): This method is only necessary until {@code ComposeView} supports
288      *  {@link AccessibilityNodeInfo#focusSearch(int)}.
289      */
isComposeView(@onNull AccessibilityNodeInfo node)290     static boolean isComposeView(@NonNull AccessibilityNodeInfo node) {
291         CharSequence className = node.getClassName();
292         return className != null && COMPOSE_VIEW_CLASS_NAME.contentEquals(className);
293     }
294 
295     /** Returns whether the given {@code node} represents a {@link SurfaceView}. */
isSurfaceView(@onNull AccessibilityNodeInfo node)296     static boolean isSurfaceView(@NonNull AccessibilityNodeInfo node) {
297         CharSequence className = node.getClassName();
298         return className != null && SURFACE_VIEW_CLASS_NAME.contentEquals(className);
299     }
300 
301     /**
302      * Returns whether the given node represents a rotary container, as indicated by its content
303      * description. This includes containers that can be scrolled using the rotary controller as
304      * well as other containers."
305      */
isRotaryContainer(@onNull AccessibilityNodeInfo node)306     static boolean isRotaryContainer(@NonNull AccessibilityNodeInfo node) {
307         CharSequence contentDescription = node.getContentDescription();
308         return contentDescription != null
309                 && (ROTARY_CONTAINER.contentEquals(contentDescription)
310                 || ROTARY_HORIZONTALLY_SCROLLABLE.contentEquals(contentDescription)
311                 || ROTARY_VERTICALLY_SCROLLABLE.contentEquals(contentDescription));
312     }
313 
314     /**
315      * Returns whether the given node represents a view which can be scrolled using the rotary
316      * controller, as indicated by its content description.
317      */
isScrollableContainer(@onNull AccessibilityNodeInfo node)318     static boolean isScrollableContainer(@NonNull AccessibilityNodeInfo node) {
319         CharSequence contentDescription = node.getContentDescription();
320         return contentDescription != null
321                 && (ROTARY_HORIZONTALLY_SCROLLABLE.contentEquals(contentDescription)
322                 || ROTARY_VERTICALLY_SCROLLABLE.contentEquals(contentDescription));
323     }
324 
325     /**
326      * Returns whether the given node represents a view which can be scrolled horizontally using the
327      * rotary controller, as indicated by its content description.
328      */
isHorizontallyScrollableContainer(@onNull AccessibilityNodeInfo node)329     static boolean isHorizontallyScrollableContainer(@NonNull AccessibilityNodeInfo node) {
330         CharSequence contentDescription = node.getContentDescription();
331         return contentDescription != null
332                 && (ROTARY_HORIZONTALLY_SCROLLABLE.contentEquals(contentDescription));
333     }
334 
335     /** Returns whether {@code descendant} is a descendant of {@code ancestor}. */
isDescendant(@onNull AccessibilityNodeInfo ancestor, @NonNull AccessibilityNodeInfo descendant)336     static boolean isDescendant(@NonNull AccessibilityNodeInfo ancestor,
337             @NonNull AccessibilityNodeInfo descendant) {
338         AccessibilityNodeInfo parent = descendant.getParent();
339         if (parent == null) {
340             return false;
341         }
342         boolean result = parent.equals(ancestor) || isDescendant(ancestor, parent);
343         recycleNode(parent);
344         return result;
345     }
346 
347     /** Recycles a window. */
recycleWindow(@ullable AccessibilityWindowInfo window)348     static void recycleWindow(@Nullable AccessibilityWindowInfo window) {
349         if (window != null) {
350             window.recycle();
351         }
352     }
353 
354     /** Recycles a list of windows. */
recycleWindows(@ullable List<AccessibilityWindowInfo> windows)355     static void recycleWindows(@Nullable List<AccessibilityWindowInfo> windows) {
356         if (windows != null) {
357             for (AccessibilityWindowInfo window : windows) {
358                 recycleWindow(window);
359             }
360         }
361     }
362 
363     /**
364      * Returns a reference to the window with ID {@code windowId} or null if not found.
365      * <p>
366      * <strong>Note:</strong> Do not recycle the result.
367      */
368     @Nullable
findWindowWithId(@onNull List<AccessibilityWindowInfo> windows, int windowId)369     static AccessibilityWindowInfo findWindowWithId(@NonNull List<AccessibilityWindowInfo> windows,
370             int windowId) {
371         for (AccessibilityWindowInfo window : windows) {
372             if (window.getId() == windowId) {
373                 return window;
374             }
375         }
376         return null;
377     }
378 
379     /** Gets the bounds in screen of the given {@code node}. */
380     @NonNull
getBoundsInScreen(@onNull AccessibilityNodeInfo node)381     static Rect getBoundsInScreen(@NonNull AccessibilityNodeInfo node) {
382         Rect bounds = new Rect();
383         node.getBoundsInScreen(bounds);
384         if (Utils.isFocusArea(node)) {
385             // For a FocusArea, the bounds used for finding the nudge target are its View bounds
386             // minus the offset.
387             Bundle bundle = node.getExtras();
388             bounds.left += bundle.getInt(FOCUS_AREA_LEFT_BOUND_OFFSET);
389             bounds.right -= bundle.getInt(FOCUS_AREA_RIGHT_BOUND_OFFSET);
390             bounds.top += bundle.getInt(FOCUS_AREA_TOP_BOUND_OFFSET);
391             bounds.bottom -= bundle.getInt(FOCUS_AREA_BOTTOM_BOUND_OFFSET);
392         } else if (node.hasExtras()) {
393             // For a view that overrides nudge bounds, the bounds used for finding the nudge target
394             // are its View bounds plus/minus the offset.
395             Bundle bundle = node.getExtras();
396             bounds.left += bundle.getInt(LEFT_BOUND_OFFSET_FOR_NUDGE);
397             bounds.right -= bundle.getInt(RIGHT_BOUND_OFFSET_FOR_NUDGE);
398             bounds.top += bundle.getInt(TOP_BOUND_OFFSET_FOR_NUDGE);
399             bounds.bottom -= bundle.getInt(BOTTOM_BOUND_OFFSET_FOR_NUDGE);
400         } else if (Utils.isRotaryContainer(node)) {
401             // For a rotary container, the bounds used for finding the nudge target are the
402             // intersection of the two bounds: (1) minimum bounds containing its children, and
403             // (2) its ancestor FocusArea's bounds, if any.
404             bounds.setEmpty();
405             Rect childBounds = new Rect();
406             for (int i = 0; i < node.getChildCount(); i++) {
407                 AccessibilityNodeInfo child = node.getChild(i);
408                 if (child != null) {
409                     child.getBoundsInScreen(childBounds);
410                     child.recycle();
411                     bounds.union(childBounds);
412                 }
413             }
414             AccessibilityNodeInfo focusArea = getAncestorFocusArea(node);
415             if (focusArea != null) {
416                 Rect focusAreaBounds = getBoundsInScreen(focusArea);
417                 bounds.setIntersect(bounds, focusAreaBounds);
418                 focusArea.recycle();
419             }
420         }
421         return bounds;
422     }
423 
424     @Nullable
getAncestorFocusArea(@onNull AccessibilityNodeInfo node)425     private static AccessibilityNodeInfo getAncestorFocusArea(@NonNull AccessibilityNodeInfo node) {
426         AccessibilityNodeInfo ancestor = node.getParent();
427         while (ancestor != null) {
428             if (isFocusArea(ancestor)) {
429                 return ancestor;
430             }
431             AccessibilityNodeInfo nextAncestor = ancestor.getParent();
432             ancestor.recycle();
433             ancestor = nextAncestor;
434         }
435         return null;
436     }
437 
438     /** Checks if the {@code componentName} is an installed input method. */
isInstalledIme(@ullable String componentName, @NonNull InputMethodManager imm)439     static boolean isInstalledIme(@Nullable String componentName,
440             @NonNull InputMethodManager imm) {
441         if (TextUtils.isEmpty(componentName)) {
442             return false;
443         }
444         // Use getInputMethodList() to get the installed input methods. Don't do that by fetching
445         // ENABLED_INPUT_METHODS and DISABLED_SYSTEM_INPUT_METHODS from the secure setting,
446         // because RotaryIME may not be included in any of them (b/229144904).
447         ComponentName component = ComponentName.unflattenFromString(componentName);
448         List<InputMethodInfo> imeList = imm.getInputMethodList();
449         for (InputMethodInfo ime : imeList) {
450             ComponentName imeComponent = ime.getComponent();
451             if (component.equals(imeComponent)) {
452                 return true;
453             }
454         }
455         return false;
456     }
457 
458     /** Retries several times to find the focused node. See b/301318227. */
459     @Nullable
findFocusWithRetry(@onNull AccessibilityNodeInfo root)460     static AccessibilityNodeInfo findFocusWithRetry(@NonNull AccessibilityNodeInfo root) {
461         AccessibilityNodeInfo focusedNode;
462         for (int i = 0; i < FIND_FOCUS_MAX_TRY_COUNT; i++) {
463             focusedNode = root.findFocus(FOCUS_INPUT);
464             L.v("findFocus():" + focusedNode);
465             focusedNode = Utils.refreshNode(focusedNode);
466             if (focusedNode != null && focusedNode.isFocused()) {
467                 return focusedNode;
468             }
469             Utils.recycleNode(focusedNode);
470             L.w("Retry findFocus()");
471         }
472         L.e("Failed to find focused node in " + root);
473         return null;
474     }
475 
476     /** Prints the node and its descendants. */
printDescendants(@ullable AccessibilityNodeInfo root, String indent)477     static void printDescendants(@Nullable AccessibilityNodeInfo root, String indent) {
478         if (root == null) {
479             return;
480         }
481         L.d(indent + root);
482         for (int i = 0; i < root.getChildCount(); i++) {
483             AccessibilityNodeInfo child = root.getChild(i);
484             printDescendants(child, indent + LOG_INDENT);
485             Utils.recycleNode(child);
486         }
487     }
488 }
489