Allow for multiple view roots, include accessibility focus changing between view roots

Summary: Ax mode now works with multiple view roots/windows, accessibility focus is also updated when new windows are opened.

Reviewed By: danielbuechele

Differential Revision: D9121844

fbshipit-source-id: 1da9327f5d6a784793db8076c2ad2d84e860ac1c
This commit is contained in:
Sara Valderrama
2018-08-02 09:28:22 -07:00
committed by Facebook Github Bot
parent 0b0f59f096
commit ff0b045bde
7 changed files with 164 additions and 70 deletions

View File

@@ -27,6 +27,8 @@ import com.facebook.sonar.plugins.common.MainThreadSonarReceiver;
import com.facebook.sonar.plugins.console.iface.ConsoleCommandReceiver; import com.facebook.sonar.plugins.console.iface.ConsoleCommandReceiver;
import com.facebook.sonar.plugins.console.iface.NullScriptingEnvironment; import com.facebook.sonar.plugins.console.iface.NullScriptingEnvironment;
import com.facebook.sonar.plugins.console.iface.ScriptingEnvironment; import com.facebook.sonar.plugins.console.iface.ScriptingEnvironment;
import com.facebook.sonar.plugins.inspector.descriptors.ApplicationDescriptor;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@@ -153,6 +155,9 @@ public class InspectorSonarPlugin implements SonarPlugin {
mHighlightedId = null; mHighlightedId = null;
} }
// remove any added accessibility delegates
ApplicationDescriptor.clearEditedDelegates();
mObjectTracker.clear(); mObjectTracker.clear();
mDescriptorMapping.onDisconnect(); mDescriptorMapping.onDisconnect();
mConnection = null; mConnection = null;
@@ -172,41 +177,8 @@ public class InspectorSonarPlugin implements SonarPlugin {
@Override @Override
public void onReceiveOnMainThread(SonarObject params, SonarResponder responder) public void onReceiveOnMainThread(SonarObject params, SonarResponder responder)
throws Exception { throws Exception {
final List<View> viewRoots = mApplication.getViewRoots(); // applicationWrapper is not used by accessibility, but is a common ancestor for multiple view roots
responder.success(getAXNode(trackObject(mApplication)));
ViewGroup root = null;
for (int i = viewRoots.size() - 1; i >= 0; i--) {
if (viewRoots.get(i) instanceof ViewGroup) {
root = (ViewGroup) viewRoots.get(i);
break;
}
}
if (root != null) {
// unlikely, but check to make sure accessibility functionality doesn't change
if (!ViewCompat.hasAccessibilityDelegate(root)) {
// add delegate to root to catch accessibility events so we can update focus in sonar
root.setAccessibilityDelegate(new View.AccessibilityDelegate() {
@Override
public boolean onRequestSendAccessibilityEvent(ViewGroup host, View child, AccessibilityEvent event) {
int eventType = event.getEventType();
if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED || eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED) {
mConnection.send("axFocusEvent",
new SonarObject.Builder()
.put("isFocus", eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
.build());
}
return super.onRequestSendAccessibilityEvent(host, child, event);
}
});
}
responder.success(getAXNode(trackObject(root)));
}
} }
}; };
@@ -245,21 +217,42 @@ public class InspectorSonarPlugin implements SonarPlugin {
final SonarArray ids = params.getArray("ids"); final SonarArray ids = params.getArray("ids");
final SonarArray.Builder result = new SonarArray.Builder(); final SonarArray.Builder result = new SonarArray.Builder();
// getNodes called to refresh accessibility focus
final boolean forFocusEvent = params.getBoolean("forFocusEvent");
for (int i = 0, count = ids.length(); i < count; i++) { for (int i = 0, count = ids.length(); i < count; i++) {
final String id = ids.getString(i); final String id = ids.getString(i);
final SonarObject node = getAXNode(id); final SonarObject node = getAXNode(id);
if (node != null) {
result.put(node); // sent request for non-existent node, potentially in error
} else { if (node == null) {
// some nodes may be null since we are searching through all current and previous known nodes
if (forFocusEvent) {
continue;
}
responder.error( responder.error(
new SonarObject.Builder() new SonarObject.Builder()
.put("message", "No node with given id") .put("message", "No node with given id")
.put("id", id) .put("id", id)
.build()); .build());
return; return;
} else {
// only need to get the focused node in this case
if (forFocusEvent) {
if (node.getObject("extraInfo").getBoolean("focused")) {
result.put(node);
break;
}
// normal getNodes call, put any nodes in result
} else {
result.put(node);
}
} }
} }
responder.success(new SonarObject.Builder().put("elements", result).build()); responder.success(new SonarObject.Builder().put("elements", result).build());
} }
}; };

View File

@@ -22,7 +22,7 @@ import javax.annotation.Nullable;
* data can be exposed to the inspector. * data can be exposed to the inspector.
*/ */
public abstract class NodeDescriptor<T> { public abstract class NodeDescriptor<T> {
private SonarConnection mConnection; protected SonarConnection mConnection;
private DescriptorMapping mDescriptorMapping; private DescriptorMapping mDescriptorMapping;
void setConnection(SonarConnection connection) { void setConnection(SonarConnection connection) {
@@ -63,6 +63,26 @@ public abstract class NodeDescriptor<T> {
} }
} }
/**
* Invalidate a node in the ax tree. This tells Sonar that this node is no longer valid and its properties and/or
* children have changed. This will trigger Sonar to re-query this node getting any new data.
*/
protected final void invalidateAX(final T node) {
if (mConnection != null) {
new ErrorReportingRunnable() {
@Override
protected void runOrThrow() throws Exception {
SonarArray array =
new SonarArray.Builder()
.put(new SonarObject.Builder().put("id", getId(node)).build())
.build();
SonarObject params = new SonarObject.Builder().put("nodes", array).build();
mConnection.send("invalidateAX", params);
}
}.run();
}
}
protected final boolean connected() { protected final boolean connected() {
return mConnection != null; return mConnection != null;
} }

View File

@@ -9,14 +9,18 @@
package com.facebook.sonar.plugins.inspector.descriptors; package com.facebook.sonar.plugins.inspector.descriptors;
import android.app.Activity; import android.app.Activity;
import android.support.v4.view.ViewCompat;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import com.facebook.sonar.core.SonarDynamic; import com.facebook.sonar.core.SonarDynamic;
import com.facebook.sonar.core.SonarObject; import com.facebook.sonar.core.SonarObject;
import com.facebook.sonar.plugins.inspector.ApplicationWrapper; import com.facebook.sonar.plugins.inspector.ApplicationWrapper;
import com.facebook.sonar.plugins.inspector.Named; import com.facebook.sonar.plugins.inspector.Named;
import com.facebook.sonar.plugins.inspector.NodeDescriptor; import com.facebook.sonar.plugins.inspector.NodeDescriptor;
import com.facebook.sonar.plugins.inspector.Touch; import com.facebook.sonar.plugins.inspector.Touch;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@@ -55,6 +59,50 @@ public class ApplicationDescriptor extends NodeDescriptor<ApplicationWrapper> {
} }
} }
private static List<ViewGroup> editedDelegates = new ArrayList<>();
private void setDelegates(ApplicationWrapper node) {
clearEditedDelegates();
for (View view : node.getViewRoots()) {
// unlikely, but check to make sure accessibility functionality doesn't change
if (view instanceof ViewGroup && !ViewCompat.hasAccessibilityDelegate(view)) {
// add delegate to root to catch accessibility events so we can update focus in sonar
view.setAccessibilityDelegate(new View.AccessibilityDelegate() {
@Override
public boolean onRequestSendAccessibilityEvent(ViewGroup host, View child, AccessibilityEvent event) {
if (mConnection != null) {
int eventType = event.getEventType();
if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
mConnection.send("axFocusEvent",
new SonarObject.Builder()
.put("isFocus", true)
.build());
} else if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED) {
mConnection.send("axFocusEvent",
new SonarObject.Builder()
.put("isFocus", false)
.build());
}
}
return super.onRequestSendAccessibilityEvent(host, child, event);
}
});
editedDelegates.add((ViewGroup) view);
}
}
}
public static void clearEditedDelegates() {
for (ViewGroup viewGroup : editedDelegates) {
viewGroup.setAccessibilityDelegate(null);
}
editedDelegates.clear();
}
@Override @Override
public void init(final ApplicationWrapper node) { public void init(final ApplicationWrapper node) {
node.setListener( node.setListener(
@@ -62,6 +110,8 @@ public class ApplicationDescriptor extends NodeDescriptor<ApplicationWrapper> {
@Override @Override
public void onActivityStackChanged(List<Activity> stack) { public void onActivityStackChanged(List<Activity> stack) {
invalidate(node); invalidate(node);
invalidateAX(node);
setDelegates(node);
} }
}); });
@@ -73,6 +123,8 @@ public class ApplicationDescriptor extends NodeDescriptor<ApplicationWrapper> {
if (connected()) { if (connected()) {
if (key.set(node)) { if (key.set(node)) {
invalidate(node); invalidate(node);
invalidateAX(node);
setDelegates(node);
} }
node.postDelayed(this, 1000); node.postDelayed(this, 1000);
} }
@@ -92,6 +144,11 @@ public class ApplicationDescriptor extends NodeDescriptor<ApplicationWrapper> {
return node.getApplication().getPackageName(); return node.getApplication().getPackageName();
} }
@Override
public String getAXName(ApplicationWrapper node) throws Exception {
return "Application";
}
@Override @Override
public int getChildCount(ApplicationWrapper node) { public int getChildCount(ApplicationWrapper node) {
return node.getViewRoots().size(); return node.getViewRoots().size();
@@ -110,6 +167,11 @@ public class ApplicationDescriptor extends NodeDescriptor<ApplicationWrapper> {
return view; return view;
} }
@Override
public Object getAXChildAt(ApplicationWrapper node, int index) {
return node.getViewRoots().get(index);
}
@Override @Override
public List<Named<SonarObject>> getData(ApplicationWrapper node) { public List<Named<SonarObject>> getData(ApplicationWrapper node) {
return Collections.EMPTY_LIST; return Collections.EMPTY_LIST;

View File

@@ -19,6 +19,7 @@ import android.os.Build;
import android.support.v4.view.MarginLayoutParamsCompat; import android.support.v4.view.MarginLayoutParamsCompat;
import android.support.v4.view.ViewCompat; import android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.util.Log;
import android.util.SparseArray; import android.util.SparseArray;
import android.view.Gravity; import android.view.Gravity;
import android.view.View; import android.view.View;
@@ -85,14 +86,19 @@ public class ViewDescriptor extends NodeDescriptor<View> {
} }
@Override @Override
public String getAXName(View node) { public String getAXName(View node) throws Exception {
AccessibilityNodeInfoCompat nodeInfo = ViewAccessibilityHelper.createNodeInfoFromView(node); AccessibilityNodeInfoCompat nodeInfo = ViewAccessibilityHelper.createNodeInfoFromView(node);
if (nodeInfo == null) { if (nodeInfo != null) {
return "NULL NODEINFO";
CharSequence name = nodeInfo.getClassName();
nodeInfo.recycle();
if (name != null) {
return name.toString();
}
} }
String name = nodeInfo.getClassName().toString(); return "NULL NODEINFO OR CLASSNAME";
nodeInfo.recycle();
return name;
} }
@Override @Override
@@ -418,7 +424,7 @@ public class ViewDescriptor extends NodeDescriptor<View> {
node.setSelected(value.asBoolean()); node.setSelected(value.asBoolean());
break; break;
} }
invalidate(node); invalidateAX(node);
} }
@Override @Override

View File

@@ -73,6 +73,7 @@ public class ViewGroupDescriptor extends NodeDescriptor<ViewGroup> {
if (connected()) { if (connected()) {
if (key.set(node)) { if (key.set(node)) {
invalidate(node); invalidate(node);
invalidateAX(node);
} }
final boolean hasAttachedToWindow = final boolean hasAttachedToWindow =

View File

@@ -24,6 +24,7 @@ public class LithoViewDescriptor extends NodeDescriptor<LithoView> {
@Override @Override
public void onDirtyMount(LithoView view) { public void onDirtyMount(LithoView view) {
invalidate(view); invalidate(view);
invalidateAX(view);
} }
}); });
} }

View File

@@ -89,6 +89,7 @@ type GetNodesResult = {|
type GetNodesOptions = {| type GetNodesOptions = {|
force: boolean, force: boolean,
ax: boolean, ax: boolean,
forFocusEvent?: boolean,
|}; |};
type SearchResultTree = {| type SearchResultTree = {|
@@ -436,22 +437,22 @@ export default class Layout extends SonarPlugin<InspectorState> {
}); });
}); });
this.client.subscribe('axFocusEvent', (focusEvent: AXFocusEventResult) => { this.client.subscribe('axFocusEvent', ({isFocus}: AXFocusEventResult) => {
// if focusing, need to update all elements in the tree because // if focusing, need to update all elements in the tree because
// we don't know which one now has focus // we don't know which one now has focus
const keys = focusEvent.isFocus ? Object.keys(this.state.AXelements) : []; const keys = isFocus ? Object.keys(this.state.AXelements) : [];
// if unfocusing and currently focused element exists, update only the // if unfocusing and currently focused element exists, update only the
// focused element (and only if it is loaded in tree) // focused element (and only if it is/was loaded in tree)
if ( if (
!focusEvent.isFocus && !isFocus &&
this.state.AXfocused && this.state.AXfocused &&
this.state.AXelements[this.state.AXfocused] this.state.AXelements[this.state.AXfocused]
) { ) {
keys.push(this.state.AXfocused); keys.push(this.state.AXfocused);
} }
this.getNodes(keys, {force: true, ax: true}).then( this.getNodes(keys, {force: true, ax: true, forFocusEvent: true}).then(
(elements: Array<Element>) => { (elements: Array<Element>) => {
this.dispatchAction({ this.dispatchAction({
elements, elements,
@@ -461,6 +462,17 @@ export default class Layout extends SonarPlugin<InspectorState> {
}, },
); );
}); });
this.client.subscribe(
'invalidateAX',
({nodes}: {nodes: Array<{id: ElementID}>}) => {
this.invalidate(nodes.map(node => node.id), true).then(
(elements: Array<Element>) => {
this.dispatchAction({elements, type: 'UpdateAXElements'});
},
);
},
);
} }
init() { init() {
@@ -477,14 +489,9 @@ export default class Layout extends SonarPlugin<InspectorState> {
this.client.subscribe( this.client.subscribe(
'invalidate', 'invalidate',
({nodes}: {nodes: Array<{id: ElementID}>}) => { ({nodes}: {nodes: Array<{id: ElementID}>}) => {
this.invalidate(nodes.map(node => node.id)).then( this.invalidate(nodes.map(node => node.id), false).then(
(elements: Array<Element>) => { (elements: Array<Element>) => {
if (this.state.inAXMode) { this.dispatchAction({elements, type: 'UpdateElements'});
// to be removed once trees are separate - will have own invalidate
this.dispatchAction({elements, type: 'UpdateAXElements'});
} else {
this.dispatchAction({elements, type: 'UpdateElements'});
}
}, },
); );
}, },
@@ -514,12 +521,11 @@ export default class Layout extends SonarPlugin<InspectorState> {
} }
} }
invalidate(ids: Array<ElementID>): Promise<Array<Element>> { invalidate(ids: Array<ElementID>, ax: boolean): Promise<Array<Element>> {
if (ids.length === 0) { if (ids.length === 0) {
return Promise.resolve([]); return Promise.resolve([]);
} }
const ax = this.state.inAXMode;
return this.getNodes(ids, {force: true, ax}).then( return this.getNodes(ids, {force: true, ax}).then(
(elements: Array<Element>) => { (elements: Array<Element>) => {
const children = elements const children = elements
@@ -532,9 +538,11 @@ export default class Layout extends SonarPlugin<InspectorState> {
.map(element => element.children) .map(element => element.children)
.reduce((acc, val) => acc.concat(val), []); .reduce((acc, val) => acc.concat(val), []);
return Promise.all([elements, this.invalidate(children)]).then(arr => { return Promise.all([elements, this.invalidate(children, ax)]).then(
return arr.reduce((acc, val) => acc.concat(val), []); arr => {
}); return arr.reduce((acc, val) => acc.concat(val), []);
},
);
}, },
); );
} }
@@ -570,7 +578,7 @@ export default class Layout extends SonarPlugin<InspectorState> {
ids: Array<ElementID> = [], ids: Array<ElementID> = [],
options: GetNodesOptions, options: GetNodesOptions,
): Promise<Array<Element>> { ): Promise<Array<Element>> {
const {force, ax} = options; const {force, ax, forFocusEvent} = options;
if (!force) { if (!force) {
ids = ids.filter(id => { ids = ids.filter(id => {
return ( return (
@@ -582,7 +590,10 @@ export default class Layout extends SonarPlugin<InspectorState> {
if (ids.length > 0) { if (ids.length > 0) {
performance.mark('LayoutInspectorGetNodes'); performance.mark('LayoutInspectorGetNodes');
return this.client return this.client
.call(ax ? 'getAXNodes' : 'getNodes', {ids}) .call(ax ? 'getAXNodes' : 'getNodes', {
ids,
forFocusEvent,
})
.then(({elements}: GetNodesResult) => { .then(({elements}: GetNodesResult) => {
this.props.logger.trackTimeSince('LayoutInspectorGetNodes'); this.props.logger.trackTimeSince('LayoutInspectorGetNodes');
return Promise.resolve(elements); return Promise.resolve(elements);