1 /*
2  * Copyright (C) 2019 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.compatibility.common.util;
18 
19 import static android.text.TextUtils.isEmpty;
20 
21 import android.app.Instrumentation;
22 import android.app.UiAutomation;
23 import android.graphics.Point;
24 import android.graphics.Rect;
25 import android.os.Bundle;
26 import android.view.WindowManager;
27 import android.view.accessibility.AccessibilityNodeInfo;
28 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
29 import android.view.accessibility.AccessibilityWindowInfo;
30 
31 import androidx.test.InstrumentationRegistry;
32 
33 import java.lang.reflect.Field;
34 import java.lang.reflect.Method;
35 import java.lang.reflect.Modifier;
36 import java.util.Arrays;
37 import java.util.LinkedHashMap;
38 import java.util.LinkedHashSet;
39 import java.util.List;
40 import java.util.Map;
41 import java.util.Objects;
42 import java.util.Set;
43 import java.util.function.BiFunction;
44 import java.util.function.BiPredicate;
45 import java.util.function.Consumer;
46 import java.util.function.Function;
47 import java.util.function.ToIntFunction;
48 import java.util.stream.Stream;
49 
50 /**
51  * Utilities to dump the view hierrarchy as an indented tree
52  *
53  * @see #dumpNodes(AccessibilityNodeInfo, StringBuilder)
54  * @see #wrapWithUiDump(Throwable)
55  */
56 @SuppressWarnings({"PointlessBitwiseExpression"})
57 public class UiDumpUtils {
UiDumpUtils()58     private UiDumpUtils() {}
59 
60     private static final boolean CONCISE = false;
61     private static final boolean SHOW_ACTIONS = false;
62     private static final boolean IGNORE_INVISIBLE = false;
63 
64     private static final int IGNORED_ACTIONS = 0
65             | AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS
66             | AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS
67             | AccessibilityNodeInfo.ACTION_FOCUS
68             | AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY
69             | AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY
70             | AccessibilityNodeInfo.ACTION_SELECT
71             | AccessibilityNodeInfo.ACTION_SET_SELECTION
72             | AccessibilityNodeInfo.ACTION_CLEAR_SELECTION
73             | AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT
74             | AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT
75             ;
76 
77     private static final int SPECIALLY_HANDLED_ACTIONS = 0
78             | AccessibilityNodeInfo.ACTION_CLICK
79             | AccessibilityNodeInfo.ACTION_LONG_CLICK
80             | AccessibilityNodeInfo.ACTION_EXPAND
81             | AccessibilityNodeInfo.ACTION_COLLAPSE
82             | AccessibilityNodeInfo.ACTION_FOCUS
83             | AccessibilityNodeInfo.ACTION_CLEAR_FOCUS
84             | AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
85             | AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
86             | AccessibilityNodeInfo.ACTION_SET_TEXT
87             ;
88 
89     /** name -> typical_value */
90     private static Map<String, Boolean> sNodeFlags = new LinkedHashMap<>();
91     static {
92         sNodeFlags.put("focused", false);
93         sNodeFlags.put("selected", false);
94         sNodeFlags.put("contextClickable", false);
95         sNodeFlags.put("dismissable", false);
96         sNodeFlags.put("enabled", true);
97         sNodeFlags.put("password", false);
98         sNodeFlags.put("visibleToUser", true);
99         sNodeFlags.put("contentInvalid", false);
100         sNodeFlags.put("heading", false);
101         sNodeFlags.put("showingHintText", false);
102 
103         // Less important flags below
104         // Too spammy to report all, but can uncomment what's necessary
105 
106 //        sNodeFlags.put("focusable", true);
107 //        sNodeFlags.put("accessibilityFocused", false);
108 //        sNodeFlags.put("screenReaderFocusable", true);
109 //        sNodeFlags.put("clickable", false);
110 //        sNodeFlags.put("longClickable", false);
111 //        sNodeFlags.put("checkable", false);
112 //        sNodeFlags.put("checked", false);
113 //        sNodeFlags.put("editable", false);
114 //        sNodeFlags.put("scrollable", false);
115 //        sNodeFlags.put("importantForAccessibility", true);
116 //        sNodeFlags.put("multiLine", false);
117     }
118 
119     /** action -> pictogram */
120     private static Map<AccessibilityAction, String> sNodeActions = new LinkedHashMap<>();
121     static {
sNodeActions.put(AccessibilityAction.ACTION_PASTE, "\\uD83D\\uDCCB")122         sNodeActions.put(AccessibilityAction.ACTION_PASTE, "\uD83D\uDCCB");
sNodeActions.put(AccessibilityAction.ACTION_CUT, "✂")123         sNodeActions.put(AccessibilityAction.ACTION_CUT, "✂");
sNodeActions.put(AccessibilityAction.ACTION_COPY, "⎘")124         sNodeActions.put(AccessibilityAction.ACTION_COPY, "⎘");
sNodeActions.put(AccessibilityAction.ACTION_SCROLL_BACKWARD, "←")125         sNodeActions.put(AccessibilityAction.ACTION_SCROLL_BACKWARD, "←");
sNodeActions.put(AccessibilityAction.ACTION_SCROLL_LEFT, "←")126         sNodeActions.put(AccessibilityAction.ACTION_SCROLL_LEFT, "←");
sNodeActions.put(AccessibilityAction.ACTION_SCROLL_FORWARD, "→")127         sNodeActions.put(AccessibilityAction.ACTION_SCROLL_FORWARD, "→");
sNodeActions.put(AccessibilityAction.ACTION_SCROLL_RIGHT, "→")128         sNodeActions.put(AccessibilityAction.ACTION_SCROLL_RIGHT, "→");
sNodeActions.put(AccessibilityAction.ACTION_SCROLL_DOWN, "↓")129         sNodeActions.put(AccessibilityAction.ACTION_SCROLL_DOWN, "↓");
sNodeActions.put(AccessibilityAction.ACTION_SCROLL_UP, "↑")130         sNodeActions.put(AccessibilityAction.ACTION_SCROLL_UP, "↑");
131     }
132 
133     private static Instrumentation sInstrumentation = InstrumentationRegistry.getInstrumentation();
134     private static UiAutomation sUiAutomation = sInstrumentation.getUiAutomation();
135 
136     private static int sScreenArea;
137     static {
138         Point displaySize = new Point();
139         sInstrumentation.getContext()
140                 .getSystemService(WindowManager.class)
141                 .getDefaultDisplay()
142                 .getRealSize(displaySize);
143         sScreenArea = displaySize.x * displaySize.y;
144     }
145 
146 
147     /**
148      * Wraps the given exception, with one containing UI hierrarchy {@link #dumpNodes dump}
149      * in its message.
150      *
151      * <p>
152      * Can be used together with {@link ExceptionUtils#wrappingExceptions}, e.g:
153      * {@code
154      *     ExceptionUtils.wrappingExceptions(UiDumpUtils::wrapWithUiDump, () -> {
155      *         // UI-testing code
156      *     });
157      * }
158      */
wrapWithUiDump(Throwable cause)159     public static UiDumpWrapperException wrapWithUiDump(Throwable cause) {
160         return (cause instanceof UiDumpWrapperException)
161                 ? (UiDumpWrapperException) cause
162                 : new UiDumpWrapperException(cause);
163     }
164 
165     /**
166      * Dumps UI hierarchy with a given {@code root} as indented text tree into {@code out}.
167      */
dumpNodes(AccessibilityNodeInfo root, StringBuilder out)168     public static void dumpNodes(AccessibilityNodeInfo root, StringBuilder out) {
169         if (root == null) {
170             appendNode(out, root);
171             return;
172         }
173 
174         out.append("--- ").append(root.getPackageName()).append(" ---\n|");
175 
176         recursively(root, AccessibilityNodeInfo::getChildCount, AccessibilityNodeInfo::getChild,
177                 node -> {
178                     if (appendNode(out, node)) {
179                         out.append("\n|");
180                     }
181                 },
182                 action -> node -> {
183                     out.append("  ");
184                     action.accept(node);
185                 });
186     }
187 
recursively(T node, ToIntFunction<T> getChildCount, BiFunction<T, Integer, T> getChildAt, Consumer<T> action, Function<Consumer<T>, Consumer<T>> actionChange)188     private static <T> void recursively(T node,
189             ToIntFunction<T> getChildCount, BiFunction<T, Integer, T> getChildAt,
190             Consumer<T> action, Function<Consumer<T>, Consumer<T>> actionChange) {
191         if (node == null) return;
192 
193         action.accept(node);
194         Consumer<T> childAction = actionChange.apply(action);
195 
196         int size = getChildCount.applyAsInt(node);
197         for (int i = 0; i < size; i++) {
198             recursively(getChildAt.apply(node, i),
199                     getChildCount, getChildAt, childAction, actionChange);
200         }
201     }
202 
appendWindow(AccessibilityWindowInfo window, StringBuilder out)203     private static StringBuilder appendWindow(AccessibilityWindowInfo window, StringBuilder out) {
204         if (window == null) {
205             out.append("<null window>");
206         } else {
207             if (!isEmpty(window.getTitle())) {
208                 out.append(window.getTitle());
209                 if (CONCISE) return out;
210                 out.append(" ");
211             }
212             out.append(valueToString(
213                     AccessibilityWindowInfo.class, "TYPE_", window.getType())).append(" ");
214             if (CONCISE) return out;
215             appendArea(out, window::getBoundsInScreen);
216 
217             Rect bounds = new Rect();
218             window.getBoundsInScreen(bounds);
219             out.append(bounds.width()).append("x").append(bounds.height()).append(" ");
220             if (window.isInPictureInPictureMode()) out.append("#PIP ");
221         }
222         return out;
223     }
224 
appendArea(StringBuilder out, Consumer<Rect> getBoundsInScreen)225     private static void appendArea(StringBuilder out, Consumer<Rect> getBoundsInScreen) {
226         Rect rect = new Rect();
227         getBoundsInScreen.accept(rect);
228         out.append("size:");
229         out.append(toStringRounding((float) area(rect) * 100 / sScreenArea)).append("% ");
230     }
231 
appendNode(StringBuilder out, AccessibilityNodeInfo node)232     private static boolean appendNode(StringBuilder out, AccessibilityNodeInfo node) {
233         if (node == null) {
234             out.append("<null node>");
235             return true;
236         }
237 
238         if (IGNORE_INVISIBLE && !node.isVisibleToUser()) return false;
239 
240         boolean markedClickable = false;
241         boolean markedNonFocusable = false;
242 
243         try {
244             if (node.isFocused() || node.isAccessibilityFocused()) {
245                 out.append(">");
246             }
247 
248             if ((node.getActions() & AccessibilityNodeInfo.ACTION_EXPAND) != 0) {
249                 out.append("[+] ");
250             }
251             if ((node.getActions() & AccessibilityNodeInfo.ACTION_COLLAPSE) != 0) {
252                 out.append("[-] ");
253             }
254 
255             CharSequence txt = node.getText();
256             if (node.isCheckable()) {
257                 out.append("[").append(node.isChecked() ? "X" : "_").append("] ");
258             } else if (node.isEditable()) {
259                 if (txt == null) txt = "";
260                 out.append("[");
261                 appendTextWithCursor(out, node, txt);
262                 out.append("] ");
263             } else if (node.isClickable()) {
264                 markedClickable = true;
265                 out.append("[");
266             } else if (!node.isImportantForAccessibility()) {
267                 markedNonFocusable = true;
268                 out.append("(");
269             }
270 
271             if (appendNodeText(out, node)) return true;
272         } finally {
273             backspaceIf(' ', out);
274             if (markedClickable) {
275                 out.append("]");
276                 if (node.isLongClickable()) out.append("+");
277                 out.append(" ");
278             }
279             if (markedNonFocusable) out.append(") ");
280 
281             if (CONCISE) out.append(" ");
282 
283             for (Map.Entry<String, Boolean> prop : sNodeFlags.entrySet()) {
284                 boolean value = call(node, boolGetter(prop.getKey()));
285                 if (value != prop.getValue()) {
286                     out.append("#");
287                     if (!value) out.append("not_");
288                     out.append(prop.getKey()).append(" ");
289                 }
290             }
291 
292             if (SHOW_ACTIONS) {
293                 LinkedHashSet<String> symbols = new LinkedHashSet<>();
294                 for (AccessibilityAction accessibilityAction : node.getActionList()) {
295                     String symbol = sNodeActions.get(accessibilityAction);
296                     if (symbol != null) symbols.add(symbol);
297                 }
298                 merge(symbols, "←", "→", "↔");
299                 merge(symbols, "↑", "↓", "↕");
300                 symbols.forEach(out::append);
301                 if (!symbols.isEmpty()) out.append(" ");
302 
303                 getActions(node)
304                         .map(a -> "[" + actionToString(a) + "] ")
305                         .forEach(out::append);
306             }
307 
308             Bundle extras = node.getExtras();
309             for (String extra : extras.keySet()) {
310                 if (extra.equals("AccessibilityNodeInfo.chromeRole")) continue;
311                 if (extra.equals("AccessibilityNodeInfo.roleDescription")) continue;
312                 String value = "" + extras.get(extra);
313                 if (value.isEmpty()) continue;
314                 out.append(extra).append(":").append(value).append(" ");
315             }
316         }
317         return true;
318     }
319 
appendTextWithCursor(StringBuilder out, AccessibilityNodeInfo node, CharSequence txt)320     private static StringBuilder appendTextWithCursor(StringBuilder out, AccessibilityNodeInfo node,
321             CharSequence txt) {
322         out.append(txt);
323         insertAtEnd(out, txt.length() - 1 - node.getTextSelectionStart(), "ꕯ");
324         if (node.getTextSelectionEnd() != node.getTextSelectionStart()) {
325             insertAtEnd(out, txt.length() - 1 - node.getTextSelectionEnd(), "ꕯ");
326         }
327         return out;
328     }
329 
appendNodeText(StringBuilder out, AccessibilityNodeInfo node)330     private static boolean appendNodeText(StringBuilder out, AccessibilityNodeInfo node) {
331         CharSequence txt = node.getText();
332 
333         Bundle extras = node.getExtras();
334         if (extras.containsKey("AccessibilityNodeInfo.roleDescription")) {
335             out.append("<").append(extras.getString("AccessibilityNodeInfo.chromeRole"))
336                     .append("> ");
337         } else if (extras.containsKey("AccessibilityNodeInfo.chromeRole")) {
338             out.append("<").append(extras.getString("AccessibilityNodeInfo.chromeRole"))
339                     .append("> ");
340         }
341 
342         if (CONCISE) {
343             if (!isEmpty(node.getContentDescription())) {
344                 out.append(escape(node.getContentDescription()));
345                 return true;
346             }
347             if (!isEmpty(node.getPaneTitle())) {
348                 out.append(escape(node.getPaneTitle()));
349                 return true;
350             }
351             if (!isEmpty(txt) && !node.isEditable()) {
352                 out.append('"');
353                 if (node.getTextSelectionStart() > 0 || node.getTextSelectionEnd() > 0) {
354                     appendTextWithCursor(out, node, txt);
355                 } else {
356                     out.append(escape(txt));
357                 }
358                 out.append('"');
359                 return true;
360             }
361             if (!isEmpty(node.getViewIdResourceName())) {
362                 out.append("@").append(fromLast("/", node.getViewIdResourceName()));
363                 return true;
364             }
365         }
366 
367         if (node.getParent() == null && node.getWindow() != null) {
368             appendWindow(node.getWindow(), out);
369             if (CONCISE) return true;
370             out.append(" ");
371         }
372 
373         if (!extras.containsKey("AccessibilityNodeInfo.chromeRole")) {
374             out.append(fromLast(".", node.getClassName())).append(" ");
375         }
376         ifNotEmpty(node.getViewIdResourceName(),
377                 s -> out.append("@").append(fromLast("/", s)).append(" "));
378         ifNotEmpty(node.getPaneTitle(), s -> out.append("## ").append(s).append(" "));
379         ifNotEmpty(txt, s -> out.append("\"").append(s).append("\" "));
380 
381         ifNotEmpty(node.getContentDescription(), s -> out.append("//").append(s).append(" "));
382 
383         appendArea(out, node::getBoundsInScreen);
384         return false;
385     }
386 
valueToString(Class<?> clazz, String prefix, T value)387     private static <T> String valueToString(Class<?> clazz, String prefix, T value) {
388         String s = flagsToString(clazz, prefix, value, Objects::equals);
389         if (s.isEmpty()) s = "" + value;
390         return s;
391     }
392 
flagsToString(Class<?> clazz, String prefix, T flags, BiPredicate<T, T> test)393     private static <T> String flagsToString(Class<?> clazz, String prefix, T flags,
394             BiPredicate<T, T> test) {
395         return mkStr(sb -> {
396             consts(clazz, prefix)
397                     .filter(f -> box(f.getType()).isInstance(flags))
398                     .forEach(c -> {
399                         try {
400                             if (test.test(flags, read(null, c))) {
401                                 sb.append(c.getName().substring(prefix.length())).append("|");
402                             }
403                         } catch (Exception e) {
404                             throw new RuntimeException("Error while dealing with " + c, e);
405                         }
406                     });
407             backspace(sb);
408         });
409     }
410 
411     private static Class box(Class c) {
412         return c == int.class ? Integer.class : c;
413     }
414 
415     private static Stream<Field> consts(Class<?> clazz, String prefix) {
416         return Arrays.stream(clazz.getDeclaredFields())
417                 .filter(f -> isConst(f) && f.getName().startsWith(prefix));
418     }
419 
420     private static boolean isConst(Field f) {
421         return Modifier.isStatic(f.getModifiers()) && Modifier.isFinal(f.getModifiers());
422     }
423 
424     private static Character last(StringBuilder sb) {
425         return sb.length() == 0 ? null : sb.charAt(sb.length() - 1);
426     }
427 
428     private static StringBuilder backspaceIf(char c, StringBuilder sb) {
429         if (Objects.equals(last(sb), c)) backspace(sb);
430         return sb;
431     }
432 
433     private static StringBuilder backspace(StringBuilder sb) {
434         if (sb.length() != 0) {
435             sb.deleteCharAt(sb.length() - 1);
436         }
437         return sb;
438     }
439 
440     private static String toStringRounding(float f) {
441         return f >= 5.0 ? "" + (int) f : String.format("%.1f", f);
442     }
443 
444     private static int area(Rect r) {
445         return Math.abs((r.right - r.left) * (r.bottom - r.top));
446     }
447 
448     private static String escape(CharSequence s) {
449         return mkStr(out -> {
450             for (int i = 0; i < s.length(); i++) {
451                 char c = s.charAt(i);
452                 if (c < 127 || c == 0xa0 || c >= 0x2000 && c < 0x2070) {
453                     out.append(c);
454                 } else {
455                     out.append("\\u").append(Integer.toHexString(c));
456                 }
457             }
458         });
459     }
460 
461     private static Stream<AccessibilityAction> getActions(
462             AccessibilityNodeInfo node) {
463         if (node == null) return Stream.empty();
464         return node.getActionList().stream()
465                 .filter(a -> !AccessibilityAction.ACTION_SHOW_ON_SCREEN.equals(a)
466                         && (a.getId()
467                                 & ~IGNORED_ACTIONS
468                                 & ~SPECIALLY_HANDLED_ACTIONS
469                             ) != 0);
470     }
471 
472     private static String actionToString(AccessibilityAction a) {
473         if (!isEmpty(a.getLabel())) return a.getLabel().toString();
474         return valueToString(AccessibilityAction.class, "ACTION_", a);
475     }
476 
477     private static void merge(Set<String> symbols, String a, String b, String ab) {
478         if (symbols.contains(a) && symbols.contains(b)) {
479             symbols.add(ab);
480             symbols.remove(a);
481             symbols.remove(b);
482         }
483     }
484 
485     private static String fromLast(String substr, CharSequence whole) {
486         String wholeStr = whole.toString();
487         int idx = wholeStr.lastIndexOf(substr);
488         if (idx < 0) return wholeStr;
489         return wholeStr.substring(idx + substr.length());
490     }
491 
492     private static String boolGetter(String propName) {
493         return "is" + Character.toUpperCase(propName.charAt(0)) + propName.substring(1);
494     }
495 
496     private static <T> T read(Object o, Field f) {
497         try {
498             f.setAccessible(true);
499             return (T) f.get(o);
500         } catch (Exception e) {
501             throw new RuntimeException(e);
502         }
503     }
504 
505     private static <T> T call(Object o, String methodName, Object... args) {
506         Class clazz = o instanceof Class ? (Class) o : o.getClass();
507         try {
508             Method method = clazz.getDeclaredMethod(methodName, mapToTypes(args));
509             method.setAccessible(true);
510             //noinspection unchecked
511             return (T) method.invoke(o, args);
512         } catch (NoSuchMethodException e) {
513             throw new RuntimeException(
514                     newlineSeparated(Arrays.asList(clazz.getDeclaredMethods())), e);
515         } catch (Exception e) {
516             throw new RuntimeException(e);
517         }
518     }
519 
520     private static Class[] mapToTypes(Object[] args) {
521         return Arrays.stream(args).map(Object::getClass).toArray(Class[]::new);
522     }
523 
524     private static void ifNotEmpty(CharSequence t, Consumer<CharSequence> f) {
525         if (!isEmpty(t)) {
526             f.accept(t);
527         }
528     }
529 
530     private static StringBuilder insertAtEnd(StringBuilder sb, int pos, String s) {
531         return sb.insert(sb.length() - 1 - pos, s);
532     }
533 
534     private static <T, R> R fold(List<T> l, R init, BiFunction<R, T, R> combine) {
535         R result = init;
536         for (T t : l) {
537             result = combine.apply(result, t);
538         }
539         return result;
540     }
541 
542     private static <T> String toString(List<T> l, String sep, Function<T, String> elemToStr) {
543         return fold(l, "", (a, b) -> a + sep + elemToStr.apply(b));
544     }
545 
546     private static <T> String toString(List<T> l, String sep) {
547         return toString(l, sep, String::valueOf);
548     }
549 
550     private static String newlineSeparated(List<?> l) {
551         return toString(l, "\n");
552     }
553 
554     private static String mkStr(Consumer<StringBuilder> build) {
555         StringBuilder t = new StringBuilder();
556         build.accept(t);
557         return t.toString();
558     }
559 
560     private static class UiDumpWrapperException extends RuntimeException {
561         private UiDumpWrapperException(Throwable cause) {
562             super(cause.getMessage() + "\n\nWhile displaying the following UI:\n"
563                     + mkStr(sb -> dumpNodes(sUiAutomation.getRootInActiveWindow(), sb)), cause);
564         }
565     }
566 }
567