/* * Copyright (c) 2018-present, Facebook, Inc. * * This source code is licensed under the MIT license found in the LICENSE * file in the root directory of this source tree. * */ package com.facebook.sonar.plugins.inspector.descriptors; import static com.facebook.sonar.plugins.inspector.InspectorValue.Type.Color; import static com.facebook.sonar.plugins.inspector.InspectorValue.Type.Enum; import android.annotation.TargetApi; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.os.Build; import android.support.v4.view.MarginLayoutParamsCompat; import android.support.v4.view.ViewCompat; import android.util.SparseArray; import android.view.Gravity; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewGroup.MarginLayoutParams; import android.view.ViewParent; import android.widget.FrameLayout; import android.widget.LinearLayout; import com.facebook.sonar.core.SonarDynamic; import com.facebook.sonar.core.SonarObject; import com.facebook.sonar.plugins.inspector.HighlightedOverlay; import com.facebook.sonar.plugins.inspector.InspectorValue; import com.facebook.sonar.plugins.inspector.Named; import com.facebook.sonar.plugins.inspector.NodeDescriptor; import com.facebook.sonar.plugins.inspector.Touch; import com.facebook.sonar.plugins.inspector.descriptors.utils.AccessibilityUtil; import com.facebook.sonar.plugins.inspector.descriptors.utils.EnumMapping; import com.facebook.stetho.common.android.ResourcesUtil; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import javax.annotation.Nullable; public class ViewDescriptor extends NodeDescriptor { private static Field sKeyedTagsField; private static Field sListenerInfoField; private static Field sOnClickListenerField; static { try { sKeyedTagsField = View.class.getDeclaredField("mKeyedTags"); sKeyedTagsField.setAccessible(true); sListenerInfoField = View.class.getDeclaredField("mListenerInfo"); sListenerInfoField.setAccessible(true); final String viewInfoClassName = View.class.getName() + "$ListenerInfo"; sOnClickListenerField = Class.forName(viewInfoClassName).getDeclaredField("mOnClickListener"); sOnClickListenerField.setAccessible(true); } catch (Exception ignored) { } } @Override public void init(final View node) {} @Override public String getId(View node) { return Integer.toString(System.identityHashCode(node)); } @Override public String getName(View node) { return node.getClass().getSimpleName(); } @Override public int getChildCount(View node) { return 0; } @Override public @Nullable Object getChildAt(View node, int index) { return null; } @Override public List> getData(View node) { final SonarObject.Builder viewProps = new SonarObject.Builder() .put("height", InspectorValue.mutable(node.getHeight())) .put("width", InspectorValue.mutable(node.getWidth())) .put("alpha", InspectorValue.mutable(node.getAlpha())) .put("visibility", sVisibilityMapping.get(node.getVisibility())) .put("background", fromDrawable(node.getBackground())) .put("tag", InspectorValue.mutable(node.getTag())) .put("keyedTags", getTags(node)) .put("layoutParams", getLayoutParams(node)) .put( "state", new SonarObject.Builder() .put("enabled", InspectorValue.mutable(node.isEnabled())) .put("activated", InspectorValue.mutable(node.isActivated())) .put("focused", node.isFocused()) .put("selected", InspectorValue.mutable(node.isSelected()))) .put( "bounds", new SonarObject.Builder() .put("left", InspectorValue.mutable(node.getLeft())) .put("right", InspectorValue.mutable(node.getRight())) .put("top", InspectorValue.mutable(node.getTop())) .put("bottom", InspectorValue.mutable(node.getBottom()))) .put( "padding", new SonarObject.Builder() .put("left", InspectorValue.mutable(node.getPaddingLeft())) .put("top", InspectorValue.mutable(node.getPaddingTop())) .put("right", InspectorValue.mutable(node.getPaddingRight())) .put("bottom", InspectorValue.mutable(node.getPaddingBottom()))) .put( "rotation", new SonarObject.Builder() .put("x", InspectorValue.mutable(node.getRotationX())) .put("y", InspectorValue.mutable(node.getRotationY())) .put("z", InspectorValue.mutable(node.getRotation()))) .put( "scale", new SonarObject.Builder() .put("x", InspectorValue.mutable(node.getScaleX())) .put("y", InspectorValue.mutable(node.getScaleY()))) .put( "pivot", new SonarObject.Builder() .put("x", InspectorValue.mutable(node.getPivotX())) .put("y", InspectorValue.mutable(node.getPivotY()))); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { viewProps .put("layoutDirection", sLayoutDirectionMapping.get(node.getLayoutDirection())) .put("textDirection", sTextDirectionMapping.get(node.getTextDirection())) .put("textAlignment", sTextAlignmentMapping.get(node.getTextAlignment())); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { viewProps.put("elevation", InspectorValue.mutable(node.getElevation())); } SonarObject.Builder translation = new SonarObject.Builder() .put("x", InspectorValue.mutable(node.getTranslationX())) .put("y", InspectorValue.mutable(node.getTranslationY())); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { translation.put("z", InspectorValue.mutable(node.getTranslationZ())); } viewProps.put("translation", translation); SonarObject.Builder position = new SonarObject.Builder() .put("x", InspectorValue.mutable(node.getX())) .put("y", InspectorValue.mutable(node.getY())); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { position.put("z", InspectorValue.mutable(node.getZ())); } viewProps.put("position", position); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { viewProps.put("foreground", fromDrawable(node.getForeground())); } return Arrays.asList( new Named<>("View", viewProps.build()), new Named<>("Accessibility", getAccessibilityData(node))); } private static SonarObject getAccessibilityData(View view) { final SonarObject.Builder accessibilityProps = new SonarObject.Builder(); // This needs to be an empty string to be mutable. See t20470623. CharSequence contentDescription = view.getContentDescription() != null ? view.getContentDescription() : ""; accessibilityProps.put("content-description", InspectorValue.mutable(contentDescription)); accessibilityProps.put("focusable", InspectorValue.mutable(view.isFocusable())); accessibilityProps.put("node-info", AccessibilityUtil.getAccessibilityNodeInfoProperties(view)); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { accessibilityProps.put( "important-for-accessibility", AccessibilityUtil.sImportantForAccessibilityMapping.get( view.getImportantForAccessibility())); } AccessibilityUtil.addTalkbackProperties(accessibilityProps, view); return accessibilityProps.build(); } @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) @Override public void setValue(View node, String[] path, SonarDynamic value) { if (path[0].equals("Accessibility")) { setAccessibilityValue(node, path, value); } if (!path[0].equals("View")) { return; } switch (path[1]) { case "elevation": node.setElevation(value.asFloat()); break; case "alpha": node.setAlpha(value.asFloat()); break; case "visibility": node.setVisibility(sVisibilityMapping.get(value.asString())); break; case "layoutParams": setLayoutParams(node, Arrays.copyOfRange(path, 1, path.length), value); break; case "layoutDirection": node.setLayoutDirection(sLayoutDirectionMapping.get(value.asString())); break; case "textDirection": node.setTextDirection(sTextDirectionMapping.get(value.asString())); break; case "textAlignment": node.setTextAlignment(sTextAlignmentMapping.get(value.asString())); break; case "background": node.setBackground(new ColorDrawable(value.asInt())); break; case "foreground": node.setForeground(new ColorDrawable(value.asInt())); break; case "state": switch (path[2]) { case "enabled": node.setEnabled(value.asBoolean()); break; case "activated": node.setActivated(value.asBoolean()); break; case "selected": node.setSelected(value.asBoolean()); break; } break; case "bounds": switch (path[2]) { case "left": node.setLeft(value.asInt()); break; case "top": node.setTop(value.asInt()); break; case "right": node.setRight(value.asInt()); break; case "bottom": node.setBottom(value.asInt()); break; } break; case "padding": switch (path[2]) { case "left": node.setPadding( value.asInt(), node.getPaddingTop(), node.getPaddingRight(), node.getPaddingBottom()); break; case "top": node.setPadding( node.getPaddingLeft(), value.asInt(), node.getPaddingRight(), node.getPaddingBottom()); break; case "right": node.setPadding( node.getPaddingLeft(), node.getPaddingTop(), value.asInt(), node.getPaddingBottom()); break; case "bottom": node.setPadding( node.getPaddingLeft(), node.getPaddingTop(), node.getPaddingRight(), value.asInt()); break; } break; case "rotation": switch (path[2]) { case "x": node.setRotationX(value.asFloat()); break; case "y": node.setRotationY(value.asFloat()); break; case "z": node.setRotation(value.asFloat()); break; } break; case "translation": switch (path[2]) { case "x": node.setTranslationX(value.asFloat()); break; case "y": node.setTranslationY(value.asFloat()); break; case "z": node.setTranslationZ(value.asFloat()); break; } break; case "position": switch (path[2]) { case "x": node.setX(value.asFloat()); break; case "y": node.setY(value.asFloat()); break; case "z": node.setZ(value.asFloat()); break; } break; case "scale": switch (path[2]) { case "x": node.setScaleX(value.asFloat()); break; case "y": node.setScaleY(value.asFloat()); break; } break; case "pivot": switch (path[2]) { case "x": node.setPivotY(value.asFloat()); break; case "y": node.setPivotX(value.asFloat()); break; } break; case "width": LayoutParams lpw = node.getLayoutParams(); lpw.width = value.asInt(); node.setLayoutParams(lpw); break; case "height": LayoutParams lph = node.getLayoutParams(); lph.height = value.asInt(); node.setLayoutParams(lph); break; } invalidate(node); } private void setAccessibilityValue(View node, String[] path, SonarDynamic value) { switch (path[1]) { case "focusable": node.setFocusable(value.asBoolean()); break; case "important-for-accessibility": node.setImportantForAccessibility( AccessibilityUtil.sImportantForAccessibilityMapping.get(value.asString())); break; case "content-description": node.setContentDescription(value.asString()); break; } invalidate(node); } @Override public List> getAttributes(View node) throws Exception { final List> attributes = new ArrayList<>(); final String resourceId = getResourceId(node); if (resourceId != null) { attributes.add(new Named<>("id", resourceId)); } if (sListenerInfoField != null && sOnClickListenerField != null) { final Object listenerInfo = sListenerInfoField.get(node); if (listenerInfo != null) { final OnClickListener clickListener = (OnClickListener) sOnClickListenerField.get(listenerInfo); if (clickListener != null) { attributes.add(new Named<>("onClick", clickListener.getClass().getName())); } } } return attributes; } @Nullable private static String getResourceId(View node) { final int id = node.getId(); if (id == View.NO_ID) { return null; } return ResourcesUtil.getIdStringQuietly(node.getContext(), node.getResources(), id); } @Override public void setHighlighted(View node, boolean selected) { // We need to figure out whether the given View has a parent View since margins are not // included within a View's bounds. So, in order to display the margin values for a particular // view, we need to apply an overlay on its parent rather than itself. final View targetView; final ViewParent parent = node.getParent(); if (parent instanceof View) { targetView = (View) parent; } else { targetView = node; } if (!selected) { HighlightedOverlay.removeHighlight(targetView); return; } final Rect padding = new Rect( ViewCompat.getPaddingStart(node), node.getPaddingTop(), ViewCompat.getPaddingEnd(node), node.getPaddingBottom()); final Rect margin; final ViewGroup.LayoutParams params = node.getLayoutParams(); if (params instanceof ViewGroup.MarginLayoutParams) { final ViewGroup.MarginLayoutParams marginParams = (ViewGroup.MarginLayoutParams) params; margin = new Rect( MarginLayoutParamsCompat.getMarginStart(marginParams), marginParams.topMargin, MarginLayoutParamsCompat.getMarginEnd(marginParams), marginParams.bottomMargin); } else { margin = new Rect(); } final int left = node.getLeft(); final int top = node.getTop(); final Rect contentBounds = new Rect(left, top, left + node.getWidth(), top + node.getHeight()); if (targetView == node) { // If the View doesn't have a parent View that we're applying the overlay to, then // we need to ensure that it is aligned to 0, 0, rather than its relative location to its // parent contentBounds.offset(-left, -top); } HighlightedOverlay.setHighlighted(targetView, margin, padding, contentBounds); } @Override public void hitTest(View node, Touch touch) { touch.finish(); } @Override public @Nullable String getDecoration(View obj) { return null; } @Override public boolean matches(String query, View node) throws Exception { final String resourceId = getResourceId(node); if (resourceId != null && resourceId.toLowerCase().contains(query)) { return true; } final NodeDescriptor objectDescriptor = descriptorForClass(Object.class); return objectDescriptor.matches(query, node); } private SonarObject getTags(final View node) { final SonarObject.Builder tags = new SonarObject.Builder(); if (sKeyedTagsField == null) { return tags.build(); } new ErrorReportingRunnable() { @Override protected void runOrThrow() throws Exception { final SparseArray keyedTags = (SparseArray) sKeyedTagsField.get(node); if (keyedTags != null) { for (int i = 0, count = keyedTags.size(); i < count; i++) { final String id = ResourcesUtil.getIdStringQuietly( node.getContext(), node.getResources(), keyedTags.keyAt(i)); tags.put(id, keyedTags.valueAt(i)); } } } }.run(); return tags.build(); } private static InspectorValue fromDrawable(Drawable d) { if (d instanceof ColorDrawable) { return InspectorValue.mutable(Color, ((ColorDrawable) d).getColor()); } return InspectorValue.mutable(Color, 0); } private static SonarObject getLayoutParams(View node) { final LayoutParams layoutParams = node.getLayoutParams(); final SonarObject.Builder params = new SonarObject.Builder(); params.put("width", fromSize(layoutParams.width)); params.put("height", fromSize(layoutParams.height)); if (layoutParams instanceof MarginLayoutParams) { final MarginLayoutParams marginLayoutParams = (MarginLayoutParams) layoutParams; params.put( "margin", new SonarObject.Builder() .put("left", InspectorValue.mutable(marginLayoutParams.leftMargin)) .put("top", InspectorValue.mutable(marginLayoutParams.topMargin)) .put("right", InspectorValue.mutable(marginLayoutParams.rightMargin)) .put("bottom", InspectorValue.mutable(marginLayoutParams.bottomMargin))); } if (layoutParams instanceof FrameLayout.LayoutParams) { final FrameLayout.LayoutParams frameLayoutParams = (FrameLayout.LayoutParams) layoutParams; params.put("gravity", sGravityMapping.get(frameLayoutParams.gravity)); } if (layoutParams instanceof LinearLayout.LayoutParams) { final LinearLayout.LayoutParams linearLayoutParams = (LinearLayout.LayoutParams) layoutParams; params .put("weight", InspectorValue.mutable(linearLayoutParams.weight)) .put("gravity", sGravityMapping.get(linearLayoutParams.gravity)); } return params.build(); } private void setLayoutParams(View node, String[] path, SonarDynamic value) { final LayoutParams params = node.getLayoutParams(); switch (path[0]) { case "width": params.width = toSize(value.asString()); break; case "height": params.height = toSize(value.asString()); break; case "weight": final LinearLayout.LayoutParams linearParams = (LinearLayout.LayoutParams) params; linearParams.weight = value.asFloat(); break; } if (params instanceof MarginLayoutParams) { final MarginLayoutParams marginParams = (MarginLayoutParams) params; switch (path[0]) { case "margin": switch (path[1]) { case "left": marginParams.leftMargin = value.asInt(); break; case "top": marginParams.topMargin = value.asInt(); break; case "right": marginParams.rightMargin = value.asInt(); break; case "bottom": marginParams.bottomMargin = value.asInt(); break; } break; } } if (params instanceof FrameLayout.LayoutParams) { final FrameLayout.LayoutParams frameLayoutParams = (FrameLayout.LayoutParams) params; switch (path[0]) { case "gravity": frameLayoutParams.gravity = sGravityMapping.get(value.asString()); break; } } if (params instanceof LinearLayout.LayoutParams) { final LinearLayout.LayoutParams linearParams = (LinearLayout.LayoutParams) params; switch (path[0]) { case "weight": linearParams.weight = value.asFloat(); break; case "gravity": linearParams.gravity = sGravityMapping.get(value.asString()); break; } } node.setLayoutParams(params); } private static InspectorValue fromSize(int size) { switch (size) { case LayoutParams.WRAP_CONTENT: return InspectorValue.mutable(Enum, "WRAP_CONTENT"); case LayoutParams.MATCH_PARENT: return InspectorValue.mutable(Enum, "MATCH_PARENT"); default: return InspectorValue.mutable(Enum, Integer.toString(size)); } } private static int toSize(String size) { switch (size) { case "WRAP_CONTENT": return LayoutParams.WRAP_CONTENT; case "MATCH_PARENT": return LayoutParams.MATCH_PARENT; default: return Integer.parseInt(size); } } private static final EnumMapping sVisibilityMapping = new EnumMapping("VISIBLE") { { put("VISIBLE", View.VISIBLE); put("INVISIBLE", View.INVISIBLE); put("GONE", View.GONE); } }; private static final EnumMapping sLayoutDirectionMapping = new EnumMapping("LAYOUT_DIRECTION_INHERIT") { { put("LAYOUT_DIRECTION_INHERIT", View.LAYOUT_DIRECTION_INHERIT); put("LAYOUT_DIRECTION_LOCALE", View.LAYOUT_DIRECTION_LOCALE); put("LAYOUT_DIRECTION_LTR", View.LAYOUT_DIRECTION_LTR); put("LAYOUT_DIRECTION_RTL", View.LAYOUT_DIRECTION_RTL); } }; private static final EnumMapping sTextDirectionMapping = new EnumMapping("TEXT_DIRECTION_INHERIT") { { put("TEXT_DIRECTION_INHERIT", View.TEXT_DIRECTION_INHERIT); put("TEXT_DIRECTION_FIRST_STRONG", View.TEXT_DIRECTION_FIRST_STRONG); put("TEXT_DIRECTION_ANY_RTL", View.TEXT_DIRECTION_ANY_RTL); put("TEXT_DIRECTION_LTR", View.TEXT_DIRECTION_LTR); put("TEXT_DIRECTION_RTL", View.TEXT_DIRECTION_RTL); put("TEXT_DIRECTION_LOCALE", View.TEXT_DIRECTION_LOCALE); put("TEXT_DIRECTION_FIRST_STRONG_LTR", View.TEXT_DIRECTION_FIRST_STRONG_LTR); put("TEXT_DIRECTION_FIRST_STRONG_RTL", View.TEXT_DIRECTION_FIRST_STRONG_RTL); } }; private static final EnumMapping sTextAlignmentMapping = new EnumMapping("TEXT_ALIGNMENT_INHERIT") { { put("TEXT_ALIGNMENT_INHERIT", View.TEXT_ALIGNMENT_INHERIT); put("TEXT_ALIGNMENT_GRAVITY", View.TEXT_ALIGNMENT_GRAVITY); put("TEXT_ALIGNMENT_TEXT_START", View.TEXT_ALIGNMENT_TEXT_START); put("TEXT_ALIGNMENT_TEXT_END", View.TEXT_ALIGNMENT_TEXT_END); put("TEXT_ALIGNMENT_CENTER", View.TEXT_ALIGNMENT_CENTER); put("TEXT_ALIGNMENT_VIEW_START", View.TEXT_ALIGNMENT_VIEW_START); put("TEXT_ALIGNMENT_VIEW_END", View.TEXT_ALIGNMENT_VIEW_END); } }; private static final EnumMapping sGravityMapping = new EnumMapping("NO_GRAVITY") { { put("NO_GRAVITY", Gravity.NO_GRAVITY); put("LEFT", Gravity.LEFT); put("TOP", Gravity.TOP); put("RIGHT", Gravity.RIGHT); put("BOTTOM", Gravity.BOTTOM); put("CENTER", Gravity.CENTER); put("CENTER_VERTICAL", Gravity.CENTER_VERTICAL); put("FILL_VERTICAL", Gravity.FILL_VERTICAL); put("CENTER_HORIZONTAL", Gravity.CENTER_HORIZONTAL); put("FILL_HORIZONTAL", Gravity.FILL_HORIZONTAL); } }; }