Initial commit 🎉

fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2
Co-authored-by: Sebastian McKenzie <sebmck@fb.com>
Co-authored-by: John Knox <jknox@fb.com>
Co-authored-by: Emil Sjölander <emilsj@fb.com>
Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
Daniel Büchele
2018-04-13 08:38:06 -07:00
committed by Daniel Buchele
commit fbbf8cf16b
659 changed files with 87130 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
/*
* 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 android.app.Activity;
import android.view.Window;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch;
import com.facebook.stetho.common.android.FragmentActivityAccessor;
import com.facebook.stetho.common.android.FragmentCompat;
import com.facebook.stetho.common.android.FragmentManagerAccessor;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;
public class ActivityDescriptor extends NodeDescriptor<Activity> {
@Override
public void init(Activity node) {}
@Override
public String getId(Activity node) {
return Integer.toString(System.identityHashCode(node));
}
@Override
public String getName(Activity node) {
return node.getClass().getSimpleName();
}
@Override
public int getChildCount(Activity node) {
return (node.getWindow() != null ? 1 : 0)
+ getDialogFragments(FragmentCompat.getSupportLibInstance(), node).size()
+ getDialogFragments(FragmentCompat.getFrameworkInstance(), node).size();
}
@Override
public Object getChildAt(Activity node, int index) {
if (node.getWindow() != null) {
if (index == 0) {
return node.getWindow();
} else {
index--;
}
}
final List dialogs = getDialogFragments(FragmentCompat.getSupportLibInstance(), node);
if (index < dialogs.size()) {
return dialogs.get(index);
} else {
final List supportDialogs = getDialogFragments(FragmentCompat.getFrameworkInstance(), node);
return supportDialogs.get(index - dialogs.size());
}
}
@Override
public List<Named<SonarObject>> getData(Activity node) {
return Collections.EMPTY_LIST;
}
@Override
public void setValue(Activity node, String[] path, SonarDynamic value) throws Exception {}
@Override
public List<Named<String>> getAttributes(Activity node) {
return Collections.EMPTY_LIST;
}
@Override
public void setHighlighted(Activity node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Window.class);
descriptor.setHighlighted(node.getWindow(), selected);
}
@Override
public void hitTest(Activity node, Touch touch) {
touch.continueWithOffset(0, 0, 0);
}
@Override
public @Nullable String getDecoration(Activity obj) {
return null;
}
@Override
public boolean matches(String query, Activity node) throws Exception {
NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
private static List<Object> getDialogFragments(FragmentCompat compat, Activity activity) {
if (compat == null || !compat.getFragmentActivityClass().isInstance(activity)) {
return Collections.EMPTY_LIST;
}
FragmentActivityAccessor activityAccessor = compat.forFragmentActivity();
Object fragmentManager = activityAccessor.getFragmentManager(activity);
if (fragmentManager == null) {
return Collections.EMPTY_LIST;
}
FragmentManagerAccessor fragmentManagerAccessor = compat.forFragmentManager();
List<Object> addedFragments = fragmentManagerAccessor.getAddedFragments(fragmentManager);
if (addedFragments == null) {
return Collections.EMPTY_LIST;
}
final List<Object> dialogFragments = new ArrayList<>();
for (int i = 0, N = addedFragments.size(); i < N; ++i) {
final Object fragment = addedFragments.get(i);
if (compat.getDialogFragmentClass().isInstance(fragment)) {
dialogFragments.add(fragment);
}
}
return dialogFragments;
}
}

View File

@@ -0,0 +1,161 @@
/*
* 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 android.app.Activity;
import android.view.View;
import android.view.ViewGroup;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.ApplicationWrapper;
import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;
public class ApplicationDescriptor extends NodeDescriptor<ApplicationWrapper> {
private class NodeKey {
private int[] mKey;
boolean set(ApplicationWrapper node) {
final List<View> roots = node.getViewRoots();
final int childCount = roots.size();
final int[] key = new int[childCount];
for (int i = 0; i < childCount; i++) {
final View child = roots.get(i);
key[i] = System.identityHashCode(child);
}
boolean changed = false;
if (mKey == null) {
changed = true;
} else if (mKey.length != key.length) {
changed = true;
} else {
for (int i = 0; i < childCount; i++) {
if (mKey[i] != key[i]) {
changed = true;
break;
}
}
}
mKey = key;
return changed;
}
}
@Override
public void init(final ApplicationWrapper node) {
node.setListener(
new ApplicationWrapper.ActivityStackChangedListener() {
@Override
public void onActivityStackChanged(List<Activity> stack) {
invalidate(node);
}
});
final NodeKey key = new NodeKey();
final Runnable maybeInvalidate =
new NodeDescriptor.ErrorReportingRunnable() {
@Override
public void runOrThrow() throws Exception {
if (connected()) {
if (key.set(node)) {
invalidate(node);
}
node.postDelayed(this, 1000);
}
}
};
node.postDelayed(maybeInvalidate, 1000);
}
@Override
public String getId(ApplicationWrapper node) {
return node.getApplication().getPackageName();
}
@Override
public String getName(ApplicationWrapper node) {
return node.getApplication().getPackageName();
}
@Override
public int getChildCount(ApplicationWrapper node) {
return node.getViewRoots().size();
}
@Override
public Object getChildAt(ApplicationWrapper node, int index) {
final View view = node.getViewRoots().get(index);
for (Activity activity : node.getActivityStack()) {
if (activity.getWindow().getDecorView() == view) {
return activity;
}
}
return view;
}
@Override
public List<Named<SonarObject>> getData(ApplicationWrapper node) {
return Collections.EMPTY_LIST;
}
@Override
public void setValue(ApplicationWrapper node, String[] path, SonarDynamic value) {}
@Override
public List<Named<String>> getAttributes(ApplicationWrapper node) {
return Collections.EMPTY_LIST;
}
@Override
public void setHighlighted(ApplicationWrapper node, boolean selected) throws Exception {
final int childCount = getChildCount(node);
if (childCount > 0) {
final Object topChild = getChildAt(node, childCount - 1);
final NodeDescriptor descriptor = descriptorForClass(topChild.getClass());
descriptor.setHighlighted(topChild, selected);
}
}
@Override
public void hitTest(ApplicationWrapper node, Touch touch) {
final int childCount = getChildCount(node);
for (int i = childCount - 1; i >= 0; i--) {
final Object child = getChildAt(node, i);
if (child instanceof Activity || child instanceof ViewGroup) {
touch.continueWithOffset(i, 0, 0);
return;
}
}
touch.finish();
}
@Override
public @Nullable String getDecoration(ApplicationWrapper obj) {
return null;
}
@Override
public boolean matches(String query, ApplicationWrapper node) throws Exception {
NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
}

View File

@@ -0,0 +1,81 @@
/*
* 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 android.app.Dialog;
import android.view.Window;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;
public class DialogDescriptor extends NodeDescriptor<Dialog> {
@Override
public void init(Dialog node) {}
@Override
public String getId(Dialog node) {
return Integer.toString(System.identityHashCode(node));
}
@Override
public String getName(Dialog node) {
return node.getClass().getSimpleName();
}
@Override
public int getChildCount(Dialog node) {
return node.getWindow() == null ? 0 : 1;
}
@Override
public Object getChildAt(Dialog node, int index) {
return node.getWindow();
}
@Override
public List<Named<SonarObject>> getData(Dialog node) {
return Collections.EMPTY_LIST;
}
@Override
public void setValue(Dialog node, String[] path, SonarDynamic value) {}
@Override
public List<Named<String>> getAttributes(Dialog node) {
return Collections.EMPTY_LIST;
}
@Override
public void setHighlighted(Dialog node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Window.class);
descriptor.setHighlighted(node.getWindow(), selected);
}
@Override
public void hitTest(Dialog node, Touch touch) {
touch.continueWithOffset(0, 0, 0);
}
@Override
public @Nullable String getDecoration(Dialog obj) {
return null;
}
@Override
public boolean matches(String query, Dialog node) throws Exception {
NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
}

View File

@@ -0,0 +1,90 @@
/*
* 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 android.app.Dialog;
import android.app.DialogFragment;
import android.app.Fragment;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch;
import java.util.List;
import javax.annotation.Nullable;
public class DialogFragmentDescriptor extends NodeDescriptor<DialogFragment> {
@Override
public void init(DialogFragment node) {}
@Override
public String getId(DialogFragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
return descriptor.getId(node);
}
@Override
public String getName(DialogFragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
return descriptor.getName(node);
}
@Override
public int getChildCount(DialogFragment node) {
return node.getDialog() == null ? 0 : 1;
}
@Override
public Object getChildAt(DialogFragment node, int index) {
return node.getDialog();
}
@Override
public List<Named<SonarObject>> getData(DialogFragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
return descriptor.getData(node);
}
@Override
public void setValue(DialogFragment node, String[] path, SonarDynamic value) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
descriptor.setValue(node, path, value);
}
@Override
public List<Named<String>> getAttributes(DialogFragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
return descriptor.getAttributes(node);
}
@Override
public void setHighlighted(DialogFragment node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Dialog.class);
if (node.getDialog() != null) {
descriptor.setHighlighted(node.getDialog(), selected);
}
}
@Override
public void hitTest(DialogFragment node, Touch touch) {
touch.continueWithOffset(0, 0, 0);
}
@Override
public @Nullable String getDecoration(DialogFragment obj) {
return null;
}
@Override
public boolean matches(String query, DialogFragment node) throws Exception {
NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
}

View File

@@ -0,0 +1,146 @@
/*
* 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 android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.view.View;
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 java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;
public class DrawableDescriptor extends NodeDescriptor<Drawable> {
@Override
public void init(Drawable node) {}
@Override
public String getId(Drawable node) {
return Integer.toString(System.identityHashCode(node));
}
@Override
public String getName(Drawable node) {
return node.getClass().getSimpleName();
}
@Override
public int getChildCount(Drawable node) {
return 0;
}
@Override
public @Nullable Object getChildAt(Drawable node, int index) {
return null;
}
@Override
public List<Named<SonarObject>> getData(Drawable node) {
final SonarObject.Builder props = new SonarObject.Builder();
final Rect bounds = node.getBounds();
props.put("left", InspectorValue.mutable(bounds.left));
props.put("top", InspectorValue.mutable(bounds.top));
props.put("right", InspectorValue.mutable(bounds.right));
props.put("bottom", InspectorValue.mutable(bounds.bottom));
if (hasAlphaSupport()) {
props.put("alpha", InspectorValue.mutable(node.getAlpha()));
}
return Arrays.asList(new Named<>("Drawable", props.build()));
}
@Override
public void setValue(Drawable node, String[] path, SonarDynamic value) {
final Rect bounds = node.getBounds();
switch (path[0]) {
case "Drawable":
switch (path[1]) {
case "left":
bounds.left = value.asInt();
node.setBounds(bounds);
break;
case "top":
bounds.top = value.asInt();
node.setBounds(bounds);
break;
case "right":
bounds.right = value.asInt();
node.setBounds(bounds);
break;
case "bottom":
bounds.bottom = value.asInt();
node.setBounds(bounds);
break;
case "alpha":
node.setAlpha(value.asInt());
break;
}
break;
}
}
private static boolean hasAlphaSupport() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
}
@Override
public List<Named<String>> getAttributes(Drawable node) {
return Collections.EMPTY_LIST;
}
@Override
public void setHighlighted(Drawable node, boolean selected) {
// Ensure we handle wrapping drawable
Drawable.Callback callbacks = node.getCallback();
while (callbacks instanceof Drawable) {
callbacks = ((Drawable) callbacks).getCallback();
}
if (!(callbacks instanceof View)) {
return;
}
final View callbackView = (View) callbacks;
if (selected) {
final Rect zero = new Rect();
final Rect bounds = node.getBounds();
HighlightedOverlay.setHighlighted(callbackView, zero, zero, bounds);
} else {
HighlightedOverlay.removeHighlight(callbackView);
}
}
@Override
public void hitTest(Drawable node, Touch touch) {
touch.finish();
}
@Override
public @Nullable String getDecoration(Drawable obj) {
return null;
}
@Override
public boolean matches(String query, Drawable node) throws Exception {
NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
}

View File

@@ -0,0 +1,124 @@
/*
* 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 android.app.Fragment;
import android.os.Bundle;
import android.view.View;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch;
import com.facebook.stetho.common.android.ResourcesUtil;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;
public class FragmentDescriptor extends NodeDescriptor<Fragment> {
@Override
public void init(Fragment node) {}
@Override
public String getId(Fragment node) {
return Integer.toString(System.identityHashCode(node));
}
@Override
public String getName(Fragment node) {
return node.getClass().getSimpleName();
}
@Override
public int getChildCount(Fragment node) {
return node.getView() == null ? 0 : 1;
}
@Override
public Object getChildAt(Fragment node, int index) {
return node.getView();
}
@Override
public List<Named<SonarObject>> getData(Fragment node) {
final Bundle args = node.getArguments();
if (args == null || args.isEmpty()) {
return Collections.EMPTY_LIST;
}
final SonarObject.Builder bundle = new SonarObject.Builder();
for (String key : args.keySet()) {
bundle.put(key, args.get(key));
}
return Arrays.asList(new Named<>("Arguments", bundle.build()));
}
@Override
public void setValue(Fragment node, String[] path, SonarDynamic value) {}
@Override
public List<Named<String>> getAttributes(Fragment node) {
final String resourceId = getResourceId(node);
if (resourceId == null) {
return Collections.EMPTY_LIST;
}
return Arrays.asList(new Named<>("id", resourceId));
}
@Nullable
private static String getResourceId(Fragment node) {
final int id = node.getId();
if (id == View.NO_ID || node.getHost() == null) {
return null;
}
return ResourcesUtil.getIdStringQuietly(node.getContext(), node.getResources(), id);
}
@Override
public void setHighlighted(Fragment node, boolean selected) throws Exception {
if (node.getView() == null) {
return;
}
final NodeDescriptor descriptor = descriptorForClass(View.class);
descriptor.setHighlighted(node.getView(), selected);
}
@Override
public void hitTest(Fragment node, Touch touch) {
touch.continueWithOffset(0, 0, 0);
}
@Override
public @Nullable String getDecoration(Fragment obj) {
return null;
}
@Override
public boolean matches(String query, Fragment node) throws Exception {
final String resourceId = getResourceId(node);
if (resourceId != null) {
if (resourceId.toLowerCase().contains(query)) {
return true;
}
}
final NodeDescriptor objectDescriptor = descriptorForClass(Object.class);
return objectDescriptor.matches(query, node);
}
}

View File

@@ -0,0 +1,85 @@
/*
* 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 com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;
public class ObjectDescriptor extends NodeDescriptor<Object> {
@Override
public void init(Object node) {}
@Override
public String getId(Object node) {
return Integer.toString(System.identityHashCode(node));
}
@Override
public String getName(Object node) {
return node.getClass().getName();
}
@Override
public int getChildCount(Object node) {
return 0;
}
@Override
public @Nullable Object getChildAt(Object node, int index) {
return null;
}
@Override
public List<Named<SonarObject>> getData(Object node) {
return Collections.EMPTY_LIST;
}
@Override
public void setValue(Object node, String[] path, SonarDynamic value) {}
@Override
public List<Named<String>> getAttributes(Object node) {
return Collections.EMPTY_LIST;
}
@Override
public void setHighlighted(Object node, boolean selected) {}
@Override
public void hitTest(Object node, Touch touch) {
touch.finish();
}
@Override
public @Nullable String getDecoration(Object obj) {
return null;
}
@Override
public boolean matches(String query, Object node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(node.getClass());
final List<Named<String>> attributes = descriptor.getAttributes(node);
for (Named<String> namedString : attributes) {
if (namedString.getName().equals("id")) {
if (namedString.getValue().toLowerCase().contains(query)) {
return true;
}
}
}
return descriptor.getName(node).toLowerCase().contains(query);
}
}

View File

@@ -0,0 +1,90 @@
/*
* 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 android.app.Dialog;
import android.support.v4.app.DialogFragment;
import android.support.v4.app.Fragment;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch;
import java.util.List;
import javax.annotation.Nullable;
public class SupportDialogFragmentDescriptor extends NodeDescriptor<DialogFragment> {
@Override
public void init(DialogFragment node) {}
@Override
public String getId(DialogFragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
return descriptor.getId(node);
}
@Override
public String getName(DialogFragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
return descriptor.getName(node);
}
@Override
public int getChildCount(DialogFragment node) {
return node.getDialog() == null ? 0 : 1;
}
@Override
public Object getChildAt(DialogFragment node, int index) {
return node.getDialog();
}
@Override
public List<Named<SonarObject>> getData(DialogFragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
return descriptor.getData(node);
}
@Override
public void setValue(DialogFragment node, String[] path, SonarDynamic value) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
descriptor.setValue(node, path, value);
}
@Override
public List<Named<String>> getAttributes(DialogFragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Fragment.class);
return descriptor.getAttributes(node);
}
@Override
public void setHighlighted(DialogFragment node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Dialog.class);
if (node.getDialog() != null) {
descriptor.setHighlighted(node.getDialog(), selected);
}
}
@Override
public void hitTest(DialogFragment node, Touch touch) {
touch.continueWithOffset(0, 0, 0);
}
@Override
public @Nullable String getDecoration(DialogFragment obj) {
return null;
}
@Override
public boolean matches(String query, DialogFragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
}

View File

@@ -0,0 +1,106 @@
/*
* 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 android.os.Bundle;
import android.support.v4.app.Fragment;
import android.view.View;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch;
import com.facebook.stetho.common.android.ResourcesUtil;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;
public class SupportFragmentDescriptor extends NodeDescriptor<Fragment> {
@Override
public void init(Fragment node) {}
@Override
public String getId(Fragment node) {
return Integer.toString(System.identityHashCode(node));
}
@Override
public String getName(Fragment node) {
return node.getClass().getSimpleName();
}
@Override
public int getChildCount(Fragment node) {
return node.getView() == null ? 0 : 1;
}
@Override
public @Nullable Object getChildAt(Fragment node, int index) {
return node.getView();
}
@Override
public List<Named<SonarObject>> getData(Fragment node) {
final Bundle args = node.getArguments();
if (args == null || args.isEmpty()) {
return Collections.EMPTY_LIST;
}
final SonarObject.Builder bundle = new SonarObject.Builder();
for (String key : args.keySet()) {
bundle.put(key, args.get(key));
}
return Arrays.asList(new Named<>("Arguments", bundle.build()));
}
@Override
public void setValue(Fragment node, String[] path, SonarDynamic value) {}
@Override
public List<Named<String>> getAttributes(Fragment node) {
final int id = node.getId();
if (id == View.NO_ID || node.getHost() == null) {
return Collections.EMPTY_LIST;
}
return Arrays.asList(
new Named<>(
"id", ResourcesUtil.getIdStringQuietly(node.getContext(), node.getResources(), id)));
}
@Override
public void setHighlighted(Fragment node, boolean selected) throws Exception {
if (node.getView() == null) {
return;
}
final NodeDescriptor descriptor = descriptorForClass(View.class);
descriptor.setHighlighted(node.getView(), selected);
}
@Override
public void hitTest(Fragment node, Touch touch) {
touch.continueWithOffset(0, 0, 0);
}
@Override
public @Nullable String getDecoration(Fragment obj) {
return null;
}
@Override
public boolean matches(String query, Fragment node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
}

View File

@@ -0,0 +1,132 @@
/*
* 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.Number;
import static com.facebook.sonar.plugins.inspector.InspectorValue.Type.Text;
import android.view.View;
import android.widget.TextView;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
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 java.util.ArrayList;
import java.util.List;
import javax.annotation.Nullable;
public class TextViewDescriptor extends NodeDescriptor<TextView> {
@Override
public void init(TextView node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
descriptor.init(node);
}
@Override
public String getId(TextView node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
return descriptor.getId(node);
}
@Override
public String getName(TextView node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
return descriptor.getName(node);
}
@Override
public int getChildCount(TextView node) {
return 0;
}
@Override
public @Nullable Object getChildAt(TextView node, int index) {
return null;
}
@Override
public List<Named<SonarObject>> getData(TextView node) throws Exception {
final List<Named<SonarObject>> props = new ArrayList<>();
final NodeDescriptor descriptor = descriptorForClass(View.class);
props.add(
0,
new Named<>(
"TextView",
new SonarObject.Builder()
.put("text", InspectorValue.mutable(Text, node.getText().toString()))
.put(
"textColor",
InspectorValue.mutable(Color, node.getTextColors().getDefaultColor()))
.put("textSize", InspectorValue.mutable(Number, node.getTextSize()))
.build()));
props.addAll(descriptor.getData(node));
return props;
}
@Override
public void setValue(TextView node, String[] path, SonarDynamic value) throws Exception {
switch (path[0]) {
case "TextView":
switch (path[1]) {
case "text":
node.setText(value.asString());
break;
case "textColor":
node.setTextColor(value.asInt());
break;
case "textSize":
node.setTextSize(value.asInt());
break;
}
break;
default:
final NodeDescriptor descriptor = descriptorForClass(View.class);
descriptor.setValue(node, path, value);
break;
}
invalidate(node);
}
@Override
public List<Named<String>> getAttributes(TextView node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
return descriptor.getAttributes(node);
}
@Override
public void setHighlighted(TextView node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
descriptor.setHighlighted(node, selected);
}
@Override
public void hitTest(TextView node, Touch touch) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
descriptor.hitTest(node, touch);
}
@Override
public @Nullable String getDecoration(TextView node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
return descriptor.getDecoration(node);
}
@Override
public boolean matches(String query, TextView node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
}

View File

@@ -0,0 +1,708 @@
/*
* 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<View> {
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<Named<SonarObject>> 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<Named<String>> getAttributes(View node) throws Exception {
final List<Named<String>> 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);
}
};
}

View File

@@ -0,0 +1,273 @@
/*
* 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 android.support.v4.view.ViewGroupCompat.LAYOUT_MODE_CLIP_BOUNDS;
import static android.support.v4.view.ViewGroupCompat.LAYOUT_MODE_OPTICAL_BOUNDS;
import static com.facebook.sonar.plugins.inspector.InspectorValue.Type.Boolean;
import static com.facebook.sonar.plugins.inspector.InspectorValue.Type.Enum;
import android.os.Build;
import android.view.View;
import android.view.ViewGroup;
import com.facebook.sonar.R;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.HiddenNode;
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.stetho.common.android.FragmentCompatUtil;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Nullable;
public class ViewGroupDescriptor extends NodeDescriptor<ViewGroup> {
private class NodeKey {
private int[] mKey;
boolean set(ViewGroup node) {
final int childCount = node.getChildCount();
final int[] key = new int[childCount];
for (int i = 0; i < childCount; i++) {
final View child = node.getChildAt(i);
key[i] = System.identityHashCode(child);
}
boolean changed = false;
if (mKey == null) {
changed = true;
} else if (mKey.length != key.length) {
changed = true;
} else {
for (int i = 0; i < childCount; i++) {
if (mKey[i] != key[i]) {
changed = true;
break;
}
}
}
mKey = key;
return changed;
}
}
@Override
public void init(final ViewGroup node) {
final NodeKey key = new NodeKey();
final Runnable maybeInvalidate =
new ErrorReportingRunnable() {
@Override
public void runOrThrow() throws Exception {
if (connected()) {
if (key.set(node)) {
invalidate(node);
}
final boolean hasAttachedToWindow =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
if (!hasAttachedToWindow || node.isAttachedToWindow()) {
node.postDelayed(this, 1000);
}
}
}
};
node.postDelayed(maybeInvalidate, 1000);
}
@Override
public String getId(ViewGroup node) throws Exception {
NodeDescriptor descriptor = descriptorForClass(View.class);
return descriptor.getId(node);
}
@Override
public String getName(ViewGroup node) throws Exception {
NodeDescriptor descriptor = descriptorForClass(View.class);
return descriptor.getName(node);
}
@Override
public int getChildCount(ViewGroup node) {
int childCount = 0;
for (int i = 0, count = node.getChildCount(); i < count; i++) {
if (!(node.getChildAt(i) instanceof HiddenNode)) {
childCount++;
}
}
return childCount;
}
@Override
public @Nullable Object getChildAt(ViewGroup node, int index) {
for (int i = 0, count = node.getChildCount(); i < count; i++) {
final View child = node.getChildAt(i);
if (child instanceof HiddenNode) {
continue;
}
if (i >= index) {
final Object fragment = getAttachedFragmentForView(child);
if (fragment != null && !FragmentCompatUtil.isDialogFragment(fragment)) {
return fragment;
}
return child;
}
}
return null;
}
@Override
public List<Named<SonarObject>> getData(ViewGroup node) throws Exception {
final List<Named<SonarObject>> props = new ArrayList<>();
final NodeDescriptor descriptor = descriptorForClass(View.class);
final SonarObject.Builder vgProps = new SonarObject.Builder();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
vgProps
.put(
"layoutMode",
InspectorValue.mutable(
Enum,
node.getLayoutMode() == LAYOUT_MODE_CLIP_BOUNDS
? "LAYOUT_MODE_CLIP_BOUNDS"
: "LAYOUT_MODE_OPTICAL_BOUNDS"))
.put("clipChildren", InspectorValue.mutable(Boolean, node.getClipChildren()));
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
vgProps.put("clipToPadding", InspectorValue.mutable(Boolean, node.getClipToPadding()));
}
props.add(0, new Named<>("ViewGroup", vgProps.build()));
props.addAll(descriptor.getData(node));
return props;
}
@Override
public void setValue(ViewGroup node, String[] path, SonarDynamic value) throws Exception {
switch (path[0]) {
case "ViewGroup":
switch (path[1]) {
case "layoutMode":
switch (value.asString()) {
case "LAYOUT_MODE_CLIP_BOUNDS":
node.setLayoutMode(LAYOUT_MODE_CLIP_BOUNDS);
break;
case "LAYOUT_MODE_OPTICAL_BOUNDS":
node.setLayoutMode(LAYOUT_MODE_OPTICAL_BOUNDS);
break;
default:
node.setLayoutMode(-1);
break;
}
break;
case "clipChildren":
node.setClipChildren(value.asBoolean());
break;
case "clipToPadding":
node.setClipToPadding(value.asBoolean());
break;
}
break;
default:
final NodeDescriptor descriptor = descriptorForClass(View.class);
descriptor.setValue(node, path, value);
break;
}
invalidate(node);
}
@Override
public List<Named<String>> getAttributes(ViewGroup node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
return descriptor.getAttributes(node);
}
@Override
public void setHighlighted(ViewGroup node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
descriptor.setHighlighted(node, selected);
}
@Override
public void hitTest(ViewGroup node, Touch touch) {
for (int i = node.getChildCount() - 1; i >= 0; i--) {
final View child = node.getChildAt(i);
if (child instanceof HiddenNode
|| child.getVisibility() != View.VISIBLE
|| shouldSkip(child)) {
continue;
}
final int scrollX = node.getScrollX();
final int scrollY = node.getScrollY();
final int left = (child.getLeft() + (int) child.getTranslationX()) - scrollX;
final int top = (child.getTop() + (int) child.getTranslationY()) - scrollY;
final int right = (child.getRight() + (int) child.getTranslationX()) - scrollX;
final int bottom = (child.getBottom() + (int) child.getTranslationY()) - scrollY;
final boolean hit = touch.containedIn(left, top, right, bottom);
if (hit) {
touch.continueWithOffset(i, left, top);
return;
}
}
touch.finish();
}
private static boolean shouldSkip(View view) {
Object tag = view.getTag(R.id.sonar_skip_view_traversal);
if (!(tag instanceof Boolean)) {
return false;
}
return (Boolean) tag;
}
@Override
public @Nullable String getDecoration(ViewGroup obj) {
return null;
}
@Override
public boolean matches(String query, ViewGroup node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
private static Object getAttachedFragmentForView(View v) {
try {
final Object fragment = FragmentCompatUtil.findFragmentForView(v);
boolean added = false;
if (fragment instanceof android.app.Fragment) {
added = ((android.app.Fragment) fragment).isAdded();
} else if (fragment instanceof android.support.v4.app.Fragment) {
added = ((android.support.v4.app.Fragment) fragment).isAdded();
}
return added ? fragment : null;
} catch (RuntimeException e) {
return null;
}
}
}

View File

@@ -0,0 +1,81 @@
/*
* 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 android.view.View;
import android.view.Window;
import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;
public class WindowDescriptor extends NodeDescriptor<Window> {
@Override
public void init(Window node) {}
@Override
public String getId(Window node) {
return Integer.toString(System.identityHashCode(node));
}
@Override
public String getName(Window node) {
return node.getClass().getSimpleName();
}
@Override
public int getChildCount(Window node) {
return 1;
}
@Override
public Object getChildAt(Window node, int index) {
return node.getDecorView();
}
@Override
public List<Named<SonarObject>> getData(Window node) {
return Collections.EMPTY_LIST;
}
@Override
public void setValue(Window node, String[] path, SonarDynamic value) {}
@Override
public List<Named<String>> getAttributes(Window node) {
return Collections.EMPTY_LIST;
}
@Override
public void setHighlighted(Window node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class);
descriptor.setHighlighted(node.getDecorView(), selected);
}
@Override
public void hitTest(Window node, Touch touch) {
touch.continueWithOffset(0, 0, 0);
}
@Override
public @Nullable String getDecoration(Window obj) {
return null;
}
@Override
public boolean matches(String query, Window node) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(Object.class);
return descriptor.matches(query, node);
}
}

View File

@@ -0,0 +1,383 @@
/*
* 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.utils;
import android.graphics.Rect;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import com.facebook.sonar.plugins.inspector.descriptors.utils.AccessibilityRoleUtil.AccessibilityRole;
import java.util.List;
import javax.annotation.Nullable;
/**
* This class provides utility methods for determining certain accessibility properties of {@link
* View}s and {@link AccessibilityNodeInfoCompat}s. It is porting some of the checks from {@link
* com.googlecode.eyesfree.utils.AccessibilityNodeInfoUtils}, but has stripped many features which
* are unnecessary here.
*/
public class AccessibilityEvaluationUtil {
private AccessibilityEvaluationUtil() {}
/**
* Returns whether the specified node has text or a content description.
*
* @param node The node to check.
* @return {@code true} if the node has text.
*/
public static boolean hasText(@Nullable AccessibilityNodeInfoCompat node) {
return node != null
&& node.getCollectionInfo() == null
&& (!TextUtils.isEmpty(node.getText()) || !TextUtils.isEmpty(node.getContentDescription()));
}
/**
* Returns whether the supplied {@link View} and {@link AccessibilityNodeInfoCompat} would produce
* spoken feedback if it were accessibility focused. NOTE: not all speaking nodes are focusable.
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it meets the criterion for producing spoken feedback
*/
public static boolean isSpeakingNode(
@Nullable AccessibilityNodeInfoCompat node, @Nullable View view) {
if (node == null || view == null) {
return false;
}
final int important = ViewCompat.getImportantForAccessibility(view);
if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|| (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO && node.getChildCount() <= 0)) {
return false;
}
return node.isCheckable() || hasText(node) || hasNonActionableSpeakingDescendants(node, view);
}
/**
* Determines if the supplied {@link View} and {@link AccessibilityNodeInfoCompat} has any
* children which are not independently accessibility focusable and also have a spoken
* description.
*
* <p>NOTE: Accessibility services will include these children's descriptions in the closest
* focusable ancestor.
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it has any non-actionable speaking descendants within its subtree
*/
public static boolean hasNonActionableSpeakingDescendants(
@Nullable AccessibilityNodeInfoCompat node, @Nullable View view) {
if (node == null || view == null || !(view instanceof ViewGroup)) {
return false;
}
final ViewGroup viewGroup = (ViewGroup) view;
for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
final View childView = viewGroup.getChildAt(i);
if (childView == null) {
continue;
}
final AccessibilityNodeInfoCompat childNode = AccessibilityNodeInfoCompat.obtain();
try {
ViewCompat.onInitializeAccessibilityNodeInfo(childView, childNode);
if (!node.isVisibleToUser()) {
continue;
}
if (isAccessibilityFocusable(childNode, childView)) {
continue;
}
if (isSpeakingNode(childNode, childView)) {
return true;
}
} finally {
if (childNode != null) {
childNode.recycle();
}
}
}
return false;
}
/**
* Determines if the provided {@link View} and {@link AccessibilityNodeInfoCompat} meet the
* criteria for gaining accessibility focus.
*
* <p>Note: this is evaluating general focusability by accessibility services, and does not mean
* this view will be guaranteed to be focused by specific services such as Talkback. For Talkback
* focusability, see {@link #isTalkbackFocusable(View)}
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it is possible to gain accessibility focus
*/
public static boolean isAccessibilityFocusable(
@Nullable AccessibilityNodeInfoCompat node, @Nullable View view) {
if (node == null || view == null) {
return false;
}
// Never focus invisible nodes.
if (!node.isVisibleToUser()) {
return false;
}
// Always focus "actionable" nodes.
if (isActionableForAccessibility(node)) {
return true;
}
// only focus top-level list items with non-actionable speaking children.
return isTopLevelScrollItem(node, view) && isSpeakingNode(node, view);
}
/**
* Determines whether the provided {@link View} and {@link AccessibilityNodeInfoCompat} is a
* top-level item in a scrollable container.
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it is a top-level item in a scrollable container.
*/
public static boolean isTopLevelScrollItem(
@Nullable AccessibilityNodeInfoCompat node, @Nullable View view) {
if (node == null || view == null) {
return false;
}
final View parent = (View) ViewCompat.getParentForAccessibility(view);
if (parent == null) {
return false;
}
if (node.isScrollable()) {
return true;
}
final List actionList = node.getActionList();
if (actionList.contains(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD)
|| actionList.contains(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD)) {
return true;
}
// Top-level items in a scrolling pager are actually two levels down since the first
// level items in pagers are the pages themselves.
View grandparent = (View) ViewCompat.getParentForAccessibility(parent);
if (grandparent != null
&& AccessibilityRoleUtil.getRole(grandparent) == AccessibilityRole.PAGER) {
return true;
}
AccessibilityRole parentRole = AccessibilityRoleUtil.getRole(parent);
return parentRole == AccessibilityRole.LIST
|| parentRole == AccessibilityRole.GRID
|| parentRole == AccessibilityRole.SCROLL_VIEW
|| parentRole == AccessibilityRole.HORIZONTAL_SCROLL_VIEW;
}
/**
* Returns whether a node is actionable. That is, the node supports one of {@link
* AccessibilityNodeInfoCompat#isClickable()}, {@link AccessibilityNodeInfoCompat#isFocusable()},
* or {@link AccessibilityNodeInfoCompat#isLongClickable()}.
*
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if node is actionable.
*/
public static boolean isActionableForAccessibility(@Nullable AccessibilityNodeInfoCompat node) {
if (node == null) {
return false;
}
if (node.isClickable() || node.isLongClickable() || node.isFocusable()) {
return true;
}
final List actionList = node.getActionList();
return actionList.contains(AccessibilityNodeInfoCompat.ACTION_CLICK)
|| actionList.contains(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK)
|| actionList.contains(AccessibilityNodeInfoCompat.ACTION_FOCUS);
}
/**
* Determines if any of the provided {@link View}'s and {@link AccessibilityNodeInfoCompat}'s
* ancestors can receive accessibility focus
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if an ancestor of may receive accessibility focus
*/
public static boolean hasFocusableAncestor(
@Nullable AccessibilityNodeInfoCompat node, @Nullable View view) {
if (node == null || view == null) {
return false;
}
final ViewParent parentView = ViewCompat.getParentForAccessibility(view);
if (!(parentView instanceof View)) {
return false;
}
final AccessibilityNodeInfoCompat parentNode = AccessibilityNodeInfoCompat.obtain();
try {
ViewCompat.onInitializeAccessibilityNodeInfo((View) parentView, parentNode);
if (parentNode == null) {
return false;
}
if (hasEqualBoundsToViewRoot(parentNode, (View) parentView)
&& parentNode.getChildCount() > 0) {
return false;
}
if (isAccessibilityFocusable(parentNode, (View) parentView)) {
return true;
}
if (hasFocusableAncestor(parentNode, (View) parentView)) {
return true;
}
} finally {
parentNode.recycle();
}
return false;
}
/**
* Returns whether a one given view is a descendant of another.
*
* @param view The {@link View} to evaluate
* @param potentialAncestor The potential ancestor {@link View}
* @return {@code true} if view is a descendant of potentialAncestor
*/
private static boolean viewIsDescendant(View view, View potentialAncestor) {
ViewParent parent = view.getParent();
while (parent != null) {
if (parent == potentialAncestor) {
return true;
}
parent = parent.getParent();
}
return false;
}
/**
* Returns whether a View has the same size and position as its View Root.
*
* @param view The {@link View} to evaluate
* @return {@code true} if view has equal bounds
*/
public static boolean hasEqualBoundsToViewRoot(AccessibilityNodeInfoCompat node, View view) {
AndroidRootResolver rootResolver = new AndroidRootResolver();
List<AndroidRootResolver.Root> roots = rootResolver.listActiveRoots();
if (roots != null) {
for (AndroidRootResolver.Root root : roots) {
if (view == root.view) {
return true;
}
if (viewIsDescendant(view, root.view)) {
Rect nodeBounds = new Rect();
node.getBoundsInScreen(nodeBounds);
Rect viewRootBounds = new Rect();
viewRootBounds.set(
root.param.x,
root.param.y,
root.param.width + root.param.x,
root.param.height + root.param.y);
return nodeBounds.equals(viewRootBounds);
}
}
}
return false;
}
/**
* Returns whether a given {@link View} will be focusable by Google's TalkBack screen reader.
*
* @param view The {@link View} to evaluate.
* @return {@code boolean} if the view will be ignored by TalkBack.
*/
public static boolean isTalkbackFocusable(View view) {
if (view == null) {
return false;
}
final int important = ViewCompat.getImportantForAccessibility(view);
if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO
|| important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return false;
}
// Go all the way up the tree to make sure no parent has hidden its descendants
ViewParent parent = view.getParent();
while (parent instanceof View) {
if (ViewCompat.getImportantForAccessibility((View) parent)
== ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return false;
}
parent = parent.getParent();
}
final AccessibilityNodeInfoCompat node = ViewAccessibilityHelper.createNodeInfoFromView(view);
if (node == null) {
return false;
}
// Non-leaf nodes identical in size to their View Root should not be focusable.
if (hasEqualBoundsToViewRoot(node, view) && node.getChildCount() > 0) {
return false;
}
try {
if (!node.isVisibleToUser()) {
return false;
}
if (isAccessibilityFocusable(node, view)) {
if (node.getChildCount() <= 0) {
// Leaves that are accessibility focusable are never ignored, even if they don't have a
// speakable description
return true;
} else if (isSpeakingNode(node, view)) {
// Node is focusable and has something to speak
return true;
}
// Node is focusable and has nothing to speak
return false;
}
// if view is not accessibility focusable, it needs to have text and no focusable ancestors.
if (!hasText(node)) {
return false;
}
if (!hasFocusableAncestor(node, view)) {
return true;
}
return false;
} finally {
node.recycle();
}
}
}

View File

@@ -0,0 +1,120 @@
/*
* 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.utils;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import javax.annotation.Nullable;
/**
* Utility class that handles the addition of a "role" for accessibility to either a View or
* AccessibilityNodeInfo.
*/
public class AccessibilityRoleUtil {
/**
* These roles are defined by Google's TalkBack screen reader, and this list should be kept up to
* date with their implementation. Details can be seen in their source code here:
*
* <p>https://github.com/google/talkback/blob/master/utils/src/main/java/Role.java
*/
public enum AccessibilityRole {
NONE(null),
BUTTON("android.widget.Button"),
CHECK_BOX("android.widget.CompoundButton"),
DROP_DOWN_LIST("android.widget.Spinner"),
EDIT_TEXT("android.widget.EditText"),
GRID("android.widget.GridView"),
IMAGE("android.widget.ImageView"),
IMAGE_BUTTON("android.widget.ImageView"),
LIST("android.widget.AbsListView"),
PAGER("android.support.v4.view.ViewPager"),
RADIO_BUTTON("android.widget.RadioButton"),
SEEK_CONTROL("android.widget.SeekBar"),
SWITCH("android.widget.Switch"),
TAB_BAR("android.widget.TabWidget"),
TOGGLE_BUTTON("android.widget.ToggleButton"),
VIEW_GROUP("android.view.ViewGroup"),
WEB_VIEW("android.webkit.WebView"),
CHECKED_TEXT_VIEW("android.widget.CheckedTextView"),
PROGRESS_BAR("android.widget.ProgressBar"),
ACTION_BAR_TAB("android.app.ActionBar$Tab"),
DRAWER_LAYOUT("android.support.v4.widget.DrawerLayout"),
SLIDING_DRAWER("android.widget.SlidingDrawer"),
ICON_MENU("com.android.internal.view.menu.IconMenuView"),
TOAST("android.widget.Toast$TN"),
DATE_PICKER_DIALOG("android.app.DatePickerDialog"),
TIME_PICKER_DIALOG("android.app.TimePickerDialog"),
DATE_PICKER("android.widget.DatePicker"),
TIME_PICKER("android.widget.TimePicker"),
NUMBER_PICKER("android.widget.NumberPicker"),
SCROLL_VIEW("android.widget.ScrollView"),
HORIZONTAL_SCROLL_VIEW("android.widget.HorizontalScrollView"),
KEYBOARD_KEY("android.inputmethodservice.Keyboard$Key");
@Nullable private final String mValue;
AccessibilityRole(String type) {
mValue = type;
}
@Nullable
public String getValue() {
return mValue;
}
public static AccessibilityRole fromValue(String value) {
for (AccessibilityRole role : AccessibilityRole.values()) {
if (role.getValue() != null && role.getValue().equals(value)) {
return role;
}
}
return AccessibilityRole.NONE;
}
}
private AccessibilityRoleUtil() {
// No instances
}
public static AccessibilityRole getRole(View view) {
AccessibilityNodeInfoCompat nodeInfo = AccessibilityNodeInfoCompat.obtain();
ViewCompat.onInitializeAccessibilityNodeInfo(view, nodeInfo);
AccessibilityRole role = getRole(nodeInfo);
nodeInfo.recycle();
return role;
}
public static AccessibilityRole getRole(AccessibilityNodeInfo nodeInfo) {
return getRole(new AccessibilityNodeInfoCompat(nodeInfo));
}
public static AccessibilityRole getRole(AccessibilityNodeInfoCompat nodeInfo) {
AccessibilityRole role = AccessibilityRole.fromValue((String) nodeInfo.getClassName());
if (role.equals(AccessibilityRole.IMAGE_BUTTON) || role.equals(AccessibilityRole.IMAGE)) {
return nodeInfo.isClickable() ? AccessibilityRole.IMAGE_BUTTON : AccessibilityRole.IMAGE;
}
if (role.equals(AccessibilityRole.NONE)) {
AccessibilityNodeInfoCompat.CollectionInfoCompat collection = nodeInfo.getCollectionInfo();
if (collection != null) {
// RecyclerView will be classified as a list or grid.
if (collection.getRowCount() > 1 && collection.getColumnCount() > 1) {
return AccessibilityRole.GRID;
} else {
return AccessibilityRole.LIST;
}
}
}
return role;
}
}

View File

@@ -0,0 +1,357 @@
/*
* 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.utils;
import static android.content.Context.ACCESSIBILITY_SERVICE;
import android.content.Context;
import android.graphics.Rect;
import android.os.Build;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.accessibility.AccessibilityManager;
import android.widget.EditText;
import com.facebook.sonar.core.SonarArray;
import com.facebook.sonar.core.SonarObject;
import javax.annotation.Nullable;
/**
* This class provides utility methods for determining certain accessibility properties of {@link
* View}s and {@link AccessibilityNodeInfoCompat}s. It is porting some of the checks from {@link
* com.googlecode.eyesfree.utils.AccessibilityNodeInfoUtils}, but has stripped many features which
* are unnecessary here.
*/
public final class AccessibilityUtil {
private AccessibilityUtil() {}
public static final EnumMapping sAccessibilityActionMapping =
new EnumMapping("UNKNOWN") {
{
put("FOCUS", AccessibilityNodeInfoCompat.ACTION_FOCUS);
put("CLEAR_FOCUS", AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS);
put("SELECT", AccessibilityNodeInfoCompat.ACTION_SELECT);
put("CLEAR_SELECTION", AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION);
put("CLICK", AccessibilityNodeInfoCompat.ACTION_CLICK);
put("LONG_CLICK", AccessibilityNodeInfoCompat.ACTION_LONG_CLICK);
put("ACCESSIBILITY_FOCUS", AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
put(
"CLEAR_ACCESSIBILITY_FOCUS",
AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
put(
"NEXT_AT_MOVEMENT_GRANULARITY",
AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
put(
"PREVIOUS_AT_MOVEMENT_GRANULARITY",
AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
put("NEXT_HTML_ELEMENT", AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT);
put("PREVIOUS_HTML_ELEMENT", AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT);
put("SCROLL_FORWARD", AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
put("SCROLL_BACKWARD", AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
put("CUT", AccessibilityNodeInfoCompat.ACTION_CUT);
put("COPY", AccessibilityNodeInfoCompat.ACTION_COPY);
put("PASTE", AccessibilityNodeInfoCompat.ACTION_PASTE);
put("SET_SELECTION", AccessibilityNodeInfoCompat.ACTION_SET_SELECTION);
put("SET_SELECTION", AccessibilityNodeInfoCompat.ACTION_SET_SELECTION);
put("EXPAND", AccessibilityNodeInfoCompat.ACTION_EXPAND);
put("COLLAPSE", AccessibilityNodeInfoCompat.ACTION_COLLAPSE);
put("DISMISS", AccessibilityNodeInfoCompat.ACTION_DISMISS);
put("SET_TEXT", AccessibilityNodeInfoCompat.ACTION_SET_TEXT);
}
};
public static final EnumMapping sImportantForAccessibilityMapping =
new EnumMapping("AUTO") {
{
put("AUTO", View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
put("NO", View.IMPORTANT_FOR_ACCESSIBILITY_NO);
put("YES", View.IMPORTANT_FOR_ACCESSIBILITY_YES);
put("NO_HIDE_DESCENDANTS", View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
}
};
/**
* Given a {@link Context}, determine if any accessibility service is running.
*
* @param context The {@link Context} used to get the {@link AccessibilityManager}.
* @return {@code true} if an accessibility service is currently running.
*/
public static boolean isAccessibilityEnabled(Context context) {
return ((AccessibilityManager) context.getSystemService(ACCESSIBILITY_SERVICE)).isEnabled();
}
/**
* Returns a sentence describing why a given {@link View} will be ignored by Google's TalkBack
* screen reader.
*
* @param view The {@link View} to evaluate.
* @return {@code String} describing why a {@link View} is ignored.
*/
public static String getTalkbackIgnoredReasons(View view) {
final int important = ViewCompat.getImportantForAccessibility(view);
if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO) {
return "View has importantForAccessibility set to 'NO'.";
}
if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return "View has importantForAccessibility set to 'NO_HIDE_DESCENDANTS'.";
}
ViewParent parent = view.getParent();
while (parent instanceof View) {
if (ViewCompat.getImportantForAccessibility((View) parent)
== ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return "An ancestor View has importantForAccessibility set to 'NO_HIDE_DESCENDANTS'.";
}
parent = parent.getParent();
}
final AccessibilityNodeInfoCompat node = ViewAccessibilityHelper.createNodeInfoFromView(view);
if (node == null) {
return "AccessibilityNodeInfo cannot be found.";
}
try {
if (AccessibilityEvaluationUtil.hasEqualBoundsToViewRoot(node, view)) {
return "View has the same dimensions as the View Root.";
}
if (!node.isVisibleToUser()) {
return "View is not visible.";
}
if (AccessibilityEvaluationUtil.isAccessibilityFocusable(node, view)) {
return "View is actionable, but has no description.";
}
if (AccessibilityEvaluationUtil.hasText(node)) {
return "View is not actionable, and an ancestor View has co-opted its description.";
}
return "View is not actionable and has no description.";
} finally {
node.recycle();
}
}
/**
* Returns a sentence describing why a given {@link View} will be focusable by Google's TalkBack
* screen reader.
*
* @param view The {@link View} to evaluate.
* @return {@code String} describing why a {@link View} is focusable.
*/
@Nullable
public static String getTalkbackFocusableReasons(View view) {
final AccessibilityNodeInfoCompat node = ViewAccessibilityHelper.createNodeInfoFromView(view);
if (node == null) {
return null;
}
try {
final boolean hasText = AccessibilityEvaluationUtil.hasText(node);
final boolean isCheckable = node.isCheckable();
final boolean hasNonActionableSpeakingDescendants =
AccessibilityEvaluationUtil.hasNonActionableSpeakingDescendants(node, view);
if (AccessibilityEvaluationUtil.isActionableForAccessibility(node)) {
if (node.getChildCount() <= 0) {
return "View is actionable and has no children.";
} else if (hasText) {
return "View is actionable and has a description.";
} else if (isCheckable) {
return "View is actionable and checkable.";
} else if (hasNonActionableSpeakingDescendants) {
return "View is actionable and has non-actionable descendants with descriptions.";
}
}
if (AccessibilityEvaluationUtil.isTopLevelScrollItem(node, view)) {
if (hasText) {
return "View is a direct child of a scrollable container and has a description.";
} else if (isCheckable) {
return "View is a direct child of a scrollable container and is checkable.";
} else if (hasNonActionableSpeakingDescendants) {
return "View is a direct child of a scrollable container and has non-actionable "
+ "descendants with descriptions.";
}
}
if (hasText) {
return "View has a description and is not actionable, but has no actionable ancestor.";
}
return null;
} finally {
node.recycle();
}
}
/**
* Creates the text that Gogole's TalkBack screen reader will read aloud for a given {@link View}.
* This may be any combination of the {@link View}'s {@code text}, {@code contentDescription}, and
* the {@code text} and {@code contentDescription} of any ancestor {@link View}.
*
* <p>Note: This string does not include any additional semantic information that Talkback will
* read, such as "Button", or "disabled".
*
* @param view The {@link View} to evaluate.
* @return {@code String} describing why a {@link View} is focusable.
*/
@Nullable
public static CharSequence getTalkbackDescription(View view) {
final AccessibilityNodeInfoCompat node = ViewAccessibilityHelper.createNodeInfoFromView(view);
if (node == null) {
return null;
}
try {
final CharSequence contentDescription = node.getContentDescription();
final CharSequence nodeText = node.getText();
final boolean hasNodeText = !TextUtils.isEmpty(nodeText);
final boolean isEditText = view instanceof EditText;
// EditText's prioritize their own text content over a contentDescription
if (!TextUtils.isEmpty(contentDescription) && (!isEditText || !hasNodeText)) {
return contentDescription;
}
if (hasNodeText) {
return nodeText;
}
// If there are child views and no contentDescription the text of all non-focusable children,
// comma separated, becomes the description.
if (view instanceof ViewGroup) {
final StringBuilder concatChildDescription = new StringBuilder();
final String separator = ", ";
final ViewGroup viewGroup = (ViewGroup) view;
for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
final View child = viewGroup.getChildAt(i);
final AccessibilityNodeInfoCompat childNodeInfo = AccessibilityNodeInfoCompat.obtain();
ViewCompat.onInitializeAccessibilityNodeInfo(child, childNodeInfo);
CharSequence childNodeDescription = null;
if (AccessibilityEvaluationUtil.isSpeakingNode(childNodeInfo, child)
&& !AccessibilityEvaluationUtil.isAccessibilityFocusable(childNodeInfo, child)) {
childNodeDescription = getTalkbackDescription(child);
}
if (!TextUtils.isEmpty(childNodeDescription)) {
if (concatChildDescription.length() > 0) {
concatChildDescription.append(separator);
}
concatChildDescription.append(childNodeDescription);
}
childNodeInfo.recycle();
}
return concatChildDescription.length() > 0 ? concatChildDescription.toString() : null;
}
return null;
} finally {
node.recycle();
}
}
/**
* Creates a {@link SonarObject} of useful properties of AccessibilityNodeInfo, to be shown in the
* Sonar Layout Inspector. All properties are immutable since they are all derived from various
* {@link View} properties.
*
* @param view The {@link View} to derive the AccessibilityNodeInfo properties from.
* @return {@link SonarObject} containing the properties.
*/
@Nullable
public static SonarObject getAccessibilityNodeInfoProperties(View view) {
final AccessibilityNodeInfoCompat nodeInfo =
ViewAccessibilityHelper.createNodeInfoFromView(view);
if (nodeInfo == null) {
return null;
}
final SonarObject.Builder nodeInfoProps = new SonarObject.Builder();
final Rect bounds = new Rect();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
final SonarArray.Builder actionsArrayBuilder = new SonarArray.Builder();
for (AccessibilityNodeInfoCompat.AccessibilityActionCompat action :
nodeInfo.getActionList()) {
final String actionLabel = (String) action.getLabel();
if (actionLabel != null) {
actionsArrayBuilder.put(actionLabel);
} else {
actionsArrayBuilder.put(
AccessibilityUtil.sAccessibilityActionMapping.get(action.getId(), false));
}
}
nodeInfoProps.put("actions", actionsArrayBuilder.build());
}
nodeInfoProps
.put("clickable", nodeInfo.isClickable())
.put("content-description", nodeInfo.getContentDescription())
.put("text", nodeInfo.getText())
.put("focused", nodeInfo.isAccessibilityFocused())
.put("long-clickable", nodeInfo.isLongClickable())
.put("focusable", nodeInfo.isFocusable());
nodeInfo.getBoundsInParent(bounds);
nodeInfoProps.put(
"parent-bounds",
new SonarObject.Builder()
.put("width", bounds.width())
.put("height", bounds.height())
.put("top", bounds.top)
.put("left", bounds.left)
.put("bottom", bounds.bottom)
.put("right", bounds.right));
nodeInfo.getBoundsInScreen(bounds);
nodeInfoProps.put(
"screen-bounds",
new SonarObject.Builder()
.put("width", bounds.width())
.put("height", bounds.height())
.put("top", bounds.top)
.put("left", bounds.left)
.put("bottom", bounds.bottom)
.put("right", bounds.right));
nodeInfo.recycle();
return nodeInfoProps.build();
}
/**
* Modifies a {@link SonarObject.Builder} to add Talkback-specific Accessibiltiy properties to be
* shown in the Sonar Layout Inspector.
*
* @param props The {@link SonarObject.Builder} to add the properties to.
* @param view The {@link View} to derive the properties from.
*/
public static void addTalkbackProperties(SonarObject.Builder props, View view) {
if (!AccessibilityEvaluationUtil.isTalkbackFocusable(view)) {
props
.put("talkback-ignored", true)
.put("talkback-ignored-reasons", getTalkbackIgnoredReasons(view));
} else {
props
.put("talkback-focusable", true)
.put("talkback-focusable-reasons", getTalkbackFocusableReasons(view))
.put("talkback-description", getTalkbackDescription(view));
}
}
}

View File

@@ -0,0 +1,102 @@
/*
* 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.utils;
import static android.view.WindowManager.LayoutParams;
import android.os.Build;
import android.view.View;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.annotation.Nullable;
public final class AndroidRootResolver {
private static final String WINDOW_MANAGER_IMPL_CLAZZ = "android.view.WindowManagerImpl";
private static final String WINDOW_MANAGER_GLOBAL_CLAZZ = "android.view.WindowManagerGlobal";
private static final String VIEWS_FIELD = "mViews";
private static final String WINDOW_PARAMS_FIELD = "mParams";
private static final String GET_DEFAULT_IMPL = "getDefault";
private static final String GET_GLOBAL_INSTANCE = "getInstance";
private boolean initialized;
private Object windowManagerObj;
private Field viewsField;
private Field paramsField;
public static class Root {
public final View view;
public final LayoutParams param;
private Root(View view, LayoutParams param) {
this.view = view;
this.param = param;
}
}
public @Nullable List<Root> listActiveRoots() {
if (!initialized) {
initialize();
}
if (null == windowManagerObj) {
return null;
}
if (null == viewsField) {
return null;
}
if (null == paramsField) {
return null;
}
List<View> views = null;
List<LayoutParams> params = null;
try {
if (Build.VERSION.SDK_INT < 19) {
views = Arrays.asList((View[]) viewsField.get(windowManagerObj));
params = Arrays.asList((LayoutParams[]) paramsField.get(windowManagerObj));
} else {
views = (List<View>) viewsField.get(windowManagerObj);
params = (List<LayoutParams>) paramsField.get(windowManagerObj);
}
} catch (RuntimeException | IllegalAccessException re) {
return null;
}
List<Root> roots = new ArrayList<>();
for (int i = 0, stop = views.size(); i < stop; i++) {
roots.add(new Root(views.get(i), params.get(i)));
}
return roots;
}
private void initialize() {
initialized = true;
String accessClass =
Build.VERSION.SDK_INT > 16 ? WINDOW_MANAGER_GLOBAL_CLAZZ : WINDOW_MANAGER_IMPL_CLAZZ;
String instanceMethod = Build.VERSION.SDK_INT > 16 ? GET_GLOBAL_INSTANCE : GET_DEFAULT_IMPL;
try {
Class<?> clazz = Class.forName(accessClass);
Method getMethod = clazz.getMethod(instanceMethod);
windowManagerObj = getMethod.invoke(null);
viewsField = clazz.getDeclaredField(VIEWS_FIELD);
viewsField.setAccessible(true);
paramsField = clazz.getDeclaredField(WINDOW_PARAMS_FIELD);
paramsField.setAccessible(true);
} catch (InvocationTargetException | IllegalAccessException | RuntimeException | NoSuchMethodException | NoSuchFieldException | ClassNotFoundException ignored) {
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* 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.utils;
import static com.facebook.sonar.plugins.inspector.InspectorValue.Type.Enum;
import android.support.v4.util.SimpleArrayMap;
import com.facebook.sonar.plugins.inspector.InspectorValue;
public class EnumMapping {
private final SimpleArrayMap<String, Integer> mMapping = new SimpleArrayMap<>();
private final String mDefaultKey;
public EnumMapping(String defaultKey) {
mDefaultKey = defaultKey;
}
public void put(String s, int i) {
mMapping.put(s, i);
}
public InspectorValue get(final int i) {
return get(i, true);
}
public InspectorValue get(final int i, final boolean mutable) {
for (int ii = 0, count = mMapping.size(); ii < count; ii++) {
if (mMapping.valueAt(ii) == i) {
return mutable
? InspectorValue.mutable(Enum, mMapping.keyAt(ii))
: InspectorValue.immutable(Enum, mMapping.keyAt(ii));
}
}
return mutable
? InspectorValue.mutable(Enum, mDefaultKey)
: InspectorValue.immutable(Enum, mDefaultKey);
}
public int get(String s) {
if (mMapping.containsKey(s)) {
return mMapping.get(s);
}
return mMapping.get(mDefaultKey);
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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.utils;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.view.View;
import javax.annotation.Nullable;
/** Class that helps with accessibility by providing useful methods. */
public final class ViewAccessibilityHelper {
/**
* Creates and returns an {@link AccessibilityNodeInfoCompat} from the the provided {@link View}.
* Note: This does not handle recycling of the {@link AccessibilityNodeInfoCompat}.
*
* @param view The {@link View} to create the {@link AccessibilityNodeInfoCompat} from.
* @return {@link AccessibilityNodeInfoCompat}
*/
@Nullable
public static AccessibilityNodeInfoCompat createNodeInfoFromView(View view) {
if (view == null) {
return null;
}
final AccessibilityNodeInfoCompat nodeInfo = AccessibilityNodeInfoCompat.obtain();
// For some unknown reason, Android seems to occasionally throw a NPE from
// onInitializeAccessibilityNodeInfo.
try {
ViewCompat.onInitializeAccessibilityNodeInfo(view, nodeInfo);
} catch (NullPointerException e) {
if (nodeInfo != null) {
nodeInfo.recycle();
}
return null;
}
return nodeInfo;
}
}