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