// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.base.test.util;
import android.app.Activity;
import android.content.res.Resources;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.Nullable;
import org.chromium.base.Log;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import javax.annotation.CheckReturnValue;
/** Renders View hierarchies to text for debugging. */
public class ViewPrinter {
/** Options to customize rendering and printing. */
public static class Options {
public static final Options DEFAULT = new Options();
private String mLogTag = "ViewPrinter";
private boolean mPrintChildren = true;
private boolean mPrintNonVisibleViews;
private boolean mPrintResourcePackage;
public boolean mPrintViewBounds;
public Options setLogTag(String logTag) {
mLogTag = logTag;
return this;
}
public Options setPrintChildren(boolean printChildren) {
mPrintChildren = printChildren;
return this;
}
public Options setPrintNonVisibleViews(boolean printNonVisibleViews) {
mPrintNonVisibleViews = printNonVisibleViews;
return this;
}
public Options setPrintResourcePackage(boolean printResourcePackage) {
mPrintResourcePackage = printResourcePackage;
return this;
}
public Options setPrintViewBounds(boolean printViewBounds) {
mPrintViewBounds = printViewBounds;
return this;
}
}
private static final int MAX_TEXT_TO_PRINT = 50;
/**
* Print out a representation of a View hierarchy to logcat for debugging.
*
* @param rootView the root View to start at
*/
public static void printView(View rootView) {
printView(rootView, Options.DEFAULT);
}
/**
* Print out a representation of a View hierarchy to logcat for debugging.
*
* @param rootView the root View to start at
* @param options options for representing the View Hierarchy
*/
public static void printView(View rootView, Options options) {
String description = describeView(rootView, options);
for (String line : description.split("\\n")) {
Log.i(options.mLogTag, line);
}
}
/** Convenience method to print an Activity's decor view. */
public static void printActivityDecorView(Activity activity) {
printActivityDecorView(activity, Options.DEFAULT);
}
/** Convenience method to print an Activity's decor view. */
public static void printActivityDecorView(Activity activity, Options options) {
printView(decorFromActivity(activity), options);
}
/**
* Dump the representation of a View hierarchy to a String for debugging.
*
* @param rootView the root View to start at
* @return a String representing the View hierarchy
*/
@CheckReturnValue
public static String describeView(View rootView) {
return describeView(rootView, Options.DEFAULT);
}
/**
* Dump the representation of a View hierarchy to a String for debugging.
*
* @param rootView the root View to start at
* @param options options for representing the View Hierarchy
* @return a String representing the View hierarchy
*/
@CheckReturnValue
public static String describeView(View rootView, Options options) {
TreeOutput treeOutput = describeViewRecursive(rootView, options);
if (treeOutput != null) {
return treeOutput.render();
} else {
return "<root view is not visible>";
}
}
/** Convenience method to describe an Activity's decor view. */
@CheckReturnValue
public static String describeActivityDecorView(Activity activity) {
return describeActivityDecorView(activity, Options.DEFAULT);
}
/** Convenience method to describe an Activity's decor view. */
@CheckReturnValue
public static String describeActivityDecorView(Activity activity, Options options) {
return describeView(decorFromActivity(activity), options);
}
private static @Nullable TreeOutput describeViewRecursive(View rootView, Options options) {
if (!options.mPrintNonVisibleViews && rootView.getVisibility() != View.VISIBLE) {
return null;
}
StringBuilder stringBuilder = new StringBuilder();
if (rootView.getVisibility() != View.VISIBLE) {
stringBuilder.append("~ ");
}
if (rootView instanceof TextView) {
TextView v = (TextView) rootView;
CharSequence textAsCharSequence = v.getText();
String text;
if (textAsCharSequence.length() > MAX_TEXT_TO_PRINT) {
textAsCharSequence = textAsCharSequence.subSequence(0, MAX_TEXT_TO_PRINT - 5);
text = textAsCharSequence + "(...)";
} else {
text = textAsCharSequence.toString();
}
text = text.replace("\n", "\\n");
stringBuilder.append('"');
stringBuilder.append(text);
stringBuilder.append("\" | ");
}
String resourceName = getViewResourceName(rootView, options);
if (resourceName != null) {
stringBuilder.append(resourceName);
stringBuilder.append(" | ");
}
stringBuilder.append(rootView.getClass().getSimpleName());
if (options.mPrintViewBounds) {
int[] locationOnScreen = new int[2];
rootView.getLocationOnScreen(locationOnScreen);
stringBuilder.append(" | [l ");
stringBuilder.append(locationOnScreen[0]);
stringBuilder.append(", t ");
stringBuilder.append(locationOnScreen[1]);
stringBuilder.append(", w ");
stringBuilder.append(rootView.getWidth());
stringBuilder.append(", h ");
stringBuilder.append(rootView.getHeight());
stringBuilder.append("]");
}
if (!options.mPrintChildren) {
return new TreeOutput(stringBuilder.toString());
}
stringBuilder.append('\n');
TreeOutput output = new TreeOutput(stringBuilder.toString());
if (rootView instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) rootView;
for (int i = 0; i < viewGroup.getChildCount(); i++) {
TreeOutput childOutput = describeViewRecursive(viewGroup.getChildAt(i), options);
if (childOutput != null) {
output.addChild(childOutput);
}
}
}
return output;
}
private static String getViewResourceName(View v, Options options) {
Resources resources = v.getResources();
if (resources == null) {
return "<no resources>";
}
int id = v.getId();
if (id == View.NO_ID) {
return null;
}
if (options.mPrintResourcePackage) {
try {
return resources.getResourceName(id);
} catch (Resources.NotFoundException e) {
return "<invalid id>";
}
} else {
String name;
try {
name = resources.getResourceEntryName(id);
} catch (Resources.NotFoundException e) {
return "<invalid id>";
}
return String.format("@id/%s", name);
}
}
/** Get the decor view from an Activity. */
private static View decorFromActivity(Activity activity) {
return activity.getWindow().getDecorView();
}
/**
* Tree of strings to be output with a structure that looks like this:
*
* <pre>
* @id/control_container | ToolbarControlContainer
* ├── @id/toolbar_container | ToolbarViewResourceFrameLayout
* │ ╰── @id/toolbar | ToolbarPhone
* │ ├── @id/home_button | HomeButton
* │ ├── @id/location_bar | LocationBarPhone
* │ │ ├── @id/location_bar_status | StatusView
* │ │ │ ╰── @id/location_bar_status_icon_view | StatusIconView
* │ │ │ ╰── @id/location_bar_status_icon_frame | FrameLayout
* │ │ │ ╰── @id/loc_bar_status_icon | ChromeImageView
* │ │ ╰── "about:blank" | @id/url_bar | UrlBarApi26
* │ ╰── @id/toolbar_buttons | LinearLayout
* │ ├── @id/tab_switcher_button | ToggleTabStackButton
* │ ╰── @id/menu_button_wrapper | MenuButton
* │ ╰── @id/menu_button | ChromeImageButton
* ╰── @id/tab_switcher_toolbar | StartSurfaceToolbarView
* ├── @id/new_tab_view | LinearLayout
* │ ├── AppCompatImageView
* │ ╰── "New tab" | MaterialTextView
* ╰── @id/menu_anchor | FrameLayout
* ╰── @id/menu_button_wrapper | MenuButton
* ╰── @id/menu_button | ChromeImageButton
* </pre>
*/
public static class TreeOutput {
private String mLabel;
private @Nullable List<TreeOutput> mChildren;
public TreeOutput(String label) {
mLabel = label;
}
public void addChild(TreeOutput child) {
Objects.requireNonNull(child);
if (mChildren == null) {
mChildren = new ArrayList<>();
}
mChildren.add(child);
}
public String render() {
StringBuilder stringBuilder = new StringBuilder();
render("", "", stringBuilder);
return stringBuilder.toString();
}
private void render(
String structureFirstLine,
String structureOtherLines,
StringBuilder stringBuilder) {
stringBuilder.append(structureFirstLine);
stringBuilder.append(mLabel);
if (mChildren == null) {
return;
}
Iterator<TreeOutput> it = mChildren.iterator();
while (it.hasNext()) {
TreeOutput child = it.next();
String newStructureFirstLine;
String newStructureOtherLines;
if (it.hasNext()) {
// Not the last child
newStructureFirstLine = structureOtherLines + "├── ";
newStructureOtherLines = structureOtherLines + "│ ";
} else {
// Last child
newStructureFirstLine = structureOtherLines + "╰── ";
newStructureOtherLines = structureOtherLines + " ";
}
child.render(newStructureFirstLine, newStructureOtherLines, stringBuilder);
}
}
}
}