/* * Copyright (c) 2014-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. */ package com.facebook.stetho.inspector.elements.android; import android.graphics.Rect; import android.view.View; import android.view.ViewDebug; import com.facebook.stetho.common.ExceptionUtil; import com.facebook.stetho.common.LogUtil; import com.facebook.stetho.common.ReflectionUtil; import com.facebook.stetho.common.StringUtil; import com.facebook.stetho.common.android.ResourcesUtil; import com.facebook.stetho.inspector.elements.AbstractChainedDescriptor; import com.facebook.stetho.inspector.elements.AttributeAccumulator; import com.facebook.stetho.inspector.elements.ComputedStyleAccumulator; import com.facebook.stetho.inspector.elements.StyleAccumulator; import com.facebook.stetho.inspector.elements.StyleRuleNameAccumulator; import com.facebook.stetho.inspector.helper.IntegerFormatter; import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.regex.Pattern; final class ViewDescriptor extends AbstractChainedDescriptor<View> implements HighlightableDescriptor<View> { private static final String ID_NAME = "id"; private static final String NONE_VALUE = "(none)"; private static final String NONE_MAPPING = "<no mapping>"; private static final String VIEW_STYLE_RULE_NAME = "<this_view>"; private static final String ACCESSIBILITY_STYLE_RULE_NAME = "Accessibility Properties"; private final MethodInvoker mMethodInvoker; private static final boolean sHasSupportNodeInfo; static { sHasSupportNodeInfo = ReflectionUtil.tryGetClassForName( "android.support.v4.view.accessibility.AccessibilityNodeInfoCompat") != null; } /** * NOTE: Only access this via {@link #getWordBoundaryPattern}. */ @Nullable private Pattern mWordBoundaryPattern; /** * NOTE: Only access this via {@link #getViewProperties}. */ @Nullable @GuardedBy("this") private volatile List<ViewCSSProperty> mViewProperties; private Pattern getWordBoundaryPattern() { if (mWordBoundaryPattern == null) { mWordBoundaryPattern = Pattern.compile("(?<=\\p{Lower})(?=\\p{Upper})"); } return mWordBoundaryPattern; } private List<ViewCSSProperty> getViewProperties() { if (mViewProperties == null) { synchronized (this) { if (mViewProperties == null) { List<ViewCSSProperty> props = new ArrayList<>(); for (final Method method : View.class.getDeclaredMethods()) { ViewDebug.ExportedProperty annotation = method.getAnnotation( ViewDebug.ExportedProperty.class); if (annotation != null) { props.add(new MethodBackedCSSProperty( method, convertViewPropertyNameToCSSName(method.getName()), annotation)); } } for (final Field field : View.class.getDeclaredFields()) { ViewDebug.ExportedProperty annotation = field.getAnnotation( ViewDebug.ExportedProperty.class); if (annotation != null) { props.add(new FieldBackedCSSProperty( field, convertViewPropertyNameToCSSName(field.getName()), annotation)); } } Collections.sort(props, new Comparator<ViewCSSProperty>() { @Override public int compare(ViewCSSProperty lhs, ViewCSSProperty rhs) { return lhs.getCSSName().compareTo(rhs.getCSSName()); } }); mViewProperties = Collections.unmodifiableList(props); } } } return mViewProperties; } public ViewDescriptor() { this(new MethodInvoker()); } public ViewDescriptor(MethodInvoker methodInvoker) { mMethodInvoker = methodInvoker; } @Override protected String onGetNodeName(View element) { String className = element.getClass().getName(); return StringUtil.removePrefix(className, "android.view.", StringUtil.removePrefix(className, "android.widget.")); } @Override protected void onGetAttributes(View element, AttributeAccumulator attributes) { String id = getIdAttribute(element); if (id != null) { attributes.store(ID_NAME, id); } } @Override protected void onSetAttributesAsText(View element, String text) { Map<String, String> attributeToValueMap = parseSetAttributesAsTextArg(text); for (Map.Entry<String, String> entry : attributeToValueMap.entrySet()) { String methodName = "set" + capitalize(entry.getKey()); String propertyValue = entry.getValue(); mMethodInvoker.invoke(element, methodName, propertyValue); } } @Nullable private static String getIdAttribute(View element) { int id = element.getId(); if (id == View.NO_ID) { return null; } return ResourcesUtil.getIdStringQuietly(element, element.getResources(), id); } @Override @Nullable public View getViewAndBoundsForHighlighting(View element, Rect bounds) { return element; } @Nullable @Override public Object getElementToHighlightAtPosition(View element, int x, int y, Rect bounds) { bounds.set(0, 0, element.getWidth(), element.getHeight()); return element; } @Override protected void onGetStyleRuleNames(View element, StyleRuleNameAccumulator accumulator) { accumulator.store(VIEW_STYLE_RULE_NAME, false); if (sHasSupportNodeInfo) { accumulator.store(ACCESSIBILITY_STYLE_RULE_NAME, false); } } @Override protected void onGetStyles(View element, String ruleName, StyleAccumulator accumulator) { if (VIEW_STYLE_RULE_NAME.equals(ruleName)) { List<ViewCSSProperty> properties = getViewProperties(); for (int i = 0, size = properties.size(); i < size; i++) { ViewCSSProperty property = properties.get(i); try { getStyleFromValue( element, property.getCSSName(), property.getValue(element), property.getAnnotation(), accumulator); } catch (Exception e) { if (e instanceof IllegalAccessException || e instanceof InvocationTargetException) { LogUtil.e(e, "failed to get style property " + property.getCSSName() + " of element= " + element.toString()); } else { throw ExceptionUtil.propagate(e); } } } } else if (ACCESSIBILITY_STYLE_RULE_NAME.equals(ruleName)) { if (sHasSupportNodeInfo) { boolean ignored = AccessibilityNodeInfoWrapper.getIgnored(element); getStyleFromValue( element, "ignored", ignored, null, accumulator); if (ignored) { getStyleFromValue( element, "ignored-reasons", AccessibilityNodeInfoWrapper.getIgnoredReasons(element), null, accumulator); } getStyleFromValue( element, "focusable", !ignored, null, accumulator); if (!ignored) { getStyleFromValue( element, "focusable-reasons", AccessibilityNodeInfoWrapper.getFocusableReasons(element), null, accumulator); getStyleFromValue( element, "focused", AccessibilityNodeInfoWrapper.getIsAccessibilityFocused(element), null, accumulator); getStyleFromValue( element, "description", AccessibilityNodeInfoWrapper.getDescription(element), null, accumulator); getStyleFromValue( element, "actions", AccessibilityNodeInfoWrapper.getActions(element), null, accumulator); } } } } @Override protected void onGetComputedStyles(View element, ComputedStyleAccumulator styles) { styles.store("left", Integer.toString(element.getLeft())); styles.store("top", Integer.toString(element.getTop())); styles.store("right", Integer.toString(element.getRight())); styles.store("bottom", Integer.toString(element.getBottom())); } private static boolean canIntBeMappedToString(@Nullable ViewDebug.ExportedProperty annotation) { return annotation != null && annotation.mapping() != null && annotation.mapping().length > 0; } private static String mapIntToStringUsingAnnotation( int value, @Nullable ViewDebug.ExportedProperty annotation) { if (!canIntBeMappedToString(annotation)) { throw new IllegalStateException("Cannot map using this annotation"); } for (ViewDebug.IntToString map : annotation.mapping()) { if (map.from() == value) { return map.to(); } } // no mapping was found even though one was expected ): return NONE_MAPPING; } private static boolean canFlagsBeMappedToString(@Nullable ViewDebug.ExportedProperty annotation) { return annotation != null && annotation.flagMapping() != null && annotation.flagMapping().length > 0; } private static String mapFlagsToStringUsingAnnotation( int value, @Nullable ViewDebug.ExportedProperty annotation) { if (!canFlagsBeMappedToString(annotation)) { throw new IllegalStateException("Cannot map using this annotation"); } StringBuilder stringBuilder = null; boolean atLeastOneFlag = false; for (ViewDebug.FlagToString flagToString : annotation.flagMapping()) { if (flagToString.outputIf() == ((value & flagToString.mask()) == flagToString.equals())) { if (stringBuilder == null) { stringBuilder = new StringBuilder(); } if (atLeastOneFlag) { stringBuilder.append(" | "); } stringBuilder.append(flagToString.name()); atLeastOneFlag = true; } } if (atLeastOneFlag) { return stringBuilder.toString(); } else { return NONE_MAPPING; } } private String convertViewPropertyNameToCSSName(String getterName) { // Split string by uppercase characters. Thankfully since // this is the android source we don't have to worry about // internationalization funk. String[] words = getWordBoundaryPattern().split(getterName); StringBuilder result = new StringBuilder(); for (int i = 0; i < words.length; i++) { if (words[i].equals("get") || words[i].equals("m")) { continue; } result.append(words[i].toLowerCase()); if (i < words.length - 1) { result.append('-'); } } return result.toString(); } private void getStyleFromValue( View element, String name, Object value, @Nullable ViewDebug.ExportedProperty annotation, StyleAccumulator styles) { if (name.equals(ID_NAME)) { getIdStyle(element, styles); } else if (value instanceof Integer) { getStyleFromInteger(name, (Integer) value, annotation, styles); } else if (value instanceof Float) { styles.store(name, String.valueOf(value), ((Float) value) == 0.0f); } else if (value instanceof Boolean) { styles.store(name, String.valueOf(value), false); } else if (value instanceof Short) { styles.store(name, String.valueOf(value), ((Short) value) == 0); } else if (value instanceof Long) { styles.store(name, String.valueOf(value), ((Long) value) == 0); } else if (value instanceof Double) { styles.store(name, String.valueOf(value), ((Double) value) == 0.0d); } else if (value instanceof Byte) { styles.store(name, String.valueOf(value), ((Byte) value) == 0); } else if (value instanceof Character) { styles.store(name, String.valueOf(value), ((Character) value) == Character.MIN_VALUE); } else if (value instanceof CharSequence) { styles.store(name, String.valueOf(value), ((CharSequence) value).length() == 0); } else { getStylesFromObject(element, name, value, annotation, styles); } } private void getIdStyle( View element, StyleAccumulator styles) { @Nullable String id = getIdAttribute(element); if (id == null) { styles.store(ID_NAME, NONE_VALUE, false); } else { styles.store(ID_NAME, id, false); } } private void getStyleFromInteger( String name, Integer value, @Nullable ViewDebug.ExportedProperty annotation, StyleAccumulator styles) { String intValueStr = IntegerFormatter.getInstance().format(value, annotation); if (canIntBeMappedToString(annotation)) { styles.store( name, intValueStr + " (" + mapIntToStringUsingAnnotation(value, annotation) + ")", false); } else if (canFlagsBeMappedToString(annotation)) { styles.store( name, intValueStr + " (" + mapFlagsToStringUsingAnnotation(value, annotation) + ")", false); } else { Boolean defaultValue = true; // Mappable ints should always be shown, because enums don't necessarily have // logical "default" values. Thus we mark all of them as not default, so that they // show up in the inspector. if (value != 0 || canFlagsBeMappedToString(annotation) || canIntBeMappedToString(annotation)) { defaultValue = false; } styles.store(name, intValueStr, defaultValue); } } private void getStylesFromObject( View view, String name, Object value, @Nullable ViewDebug.ExportedProperty annotation, StyleAccumulator styles) { if (annotation == null || !annotation.deepExport() || value == null) { return; } Field[] fields = value.getClass().getFields(); for (Field field : fields) { int modifiers = field.getModifiers(); if (Modifier.isStatic(modifiers)) { continue; } Object propertyValue; try { field.setAccessible(true); propertyValue = field.get(value); } catch (IllegalAccessException e) { LogUtil.e( e, "failed to get property of name: \"" + name + "\" of object: " + String.valueOf(value)); return; } String propertyName = field.getName(); switch (propertyName) { case "bottomMargin": propertyName = "margin-bottom"; break; case "topMargin": propertyName = "margin-top"; break; case "leftMargin": propertyName = "margin-left"; break; case "rightMargin": propertyName = "margin-right"; break; default: String annotationPrefix = annotation.prefix(); propertyName = convertViewPropertyNameToCSSName( (annotationPrefix == null) ? propertyName : (annotationPrefix + propertyName)); break; } ViewDebug.ExportedProperty subAnnotation = field.getAnnotation(ViewDebug.ExportedProperty.class); getStyleFromValue( view, propertyName, propertyValue, subAnnotation, styles); } } private static String capitalize(String str) { if (str == null || str.length() == 0 || Character.isTitleCase(str.charAt(0))) { return str; } StringBuilder buffer = new StringBuilder(str); buffer.setCharAt(0, Character.toTitleCase(buffer.charAt(0))); return buffer.toString(); } private final class FieldBackedCSSProperty extends ViewCSSProperty { private final Field mField; public FieldBackedCSSProperty( Field field, String cssName, @Nullable ViewDebug.ExportedProperty annotation) { super(cssName, annotation); mField = field; mField.setAccessible(true); } @Override public Object getValue(View view) throws InvocationTargetException, IllegalAccessException { return mField.get(view); } } private final class MethodBackedCSSProperty extends ViewCSSProperty { private final Method mMethod; public MethodBackedCSSProperty( Method method, String cssName, @Nullable ViewDebug.ExportedProperty annotation) { super(cssName, annotation); mMethod = method; mMethod.setAccessible(true); } @Override public Object getValue(View view) throws InvocationTargetException, IllegalAccessException { return mMethod.invoke(view); } } private abstract class ViewCSSProperty { private final String mCSSName; private final ViewDebug.ExportedProperty mAnnotation; public ViewCSSProperty(String cssName, @Nullable ViewDebug.ExportedProperty annotation) { mCSSName = cssName; mAnnotation = annotation; } public final String getCSSName() { return mCSSName; } public abstract Object getValue(View view) throws InvocationTargetException, IllegalAccessException; public final @Nullable ViewDebug.ExportedProperty getAnnotation() { return mAnnotation; } } }