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