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