in shell/platform/android/io/flutter/view/AccessibilityBridge.java [553:920]
public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
if (virtualViewId >= MIN_ENGINE_GENERATED_NODE_ID) {
// The node is in the engine generated range, and is provided by the accessibility view
// embedder.
return accessibilityViewEmbedder.createAccessibilityNodeInfo(virtualViewId);
}
if (virtualViewId == View.NO_ID) {
AccessibilityNodeInfo result = AccessibilityNodeInfo.obtain(rootAccessibilityView);
rootAccessibilityView.onInitializeAccessibilityNodeInfo(result);
// TODO(mattcarroll): what does it mean for the semantics tree to contain or not contain
// the root node ID?
if (flutterSemanticsTree.containsKey(ROOT_NODE_ID)) {
result.addChild(rootAccessibilityView, ROOT_NODE_ID);
}
return result;
}
SemanticsNode semanticsNode = flutterSemanticsTree.get(virtualViewId);
if (semanticsNode == null) {
return null;
}
// Generate accessibility node for platform views using a virtual display.
//
// In this case, register the accessibility node in the view embedder,
// so the accessibility tree can be mirrored as a subtree of the Flutter accessibility tree.
// This is in constrast to hybrid composition where the embedded view is in the view hiearchy,
// so it doesn't need to be mirrored.
//
// See the case down below for how hybrid composition is handled.
if (semanticsNode.platformViewId != -1) {
View embeddedView =
platformViewsAccessibilityDelegate.getPlatformViewById(semanticsNode.platformViewId);
if (platformViewsAccessibilityDelegate.usesVirtualDisplay(semanticsNode.platformViewId)) {
Rect bounds = semanticsNode.getGlobalRect();
return accessibilityViewEmbedder.getRootNode(embeddedView, semanticsNode.id, bounds);
}
}
AccessibilityNodeInfo result =
obtainAccessibilityNodeInfo(rootAccessibilityView, virtualViewId);
// Work around for https://github.com/flutter/flutter/issues/2101
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
result.setViewIdResourceName("");
}
result.setPackageName(rootAccessibilityView.getContext().getPackageName());
result.setClassName("android.view.View");
result.setSource(rootAccessibilityView, virtualViewId);
result.setFocusable(semanticsNode.isFocusable());
if (inputFocusedSemanticsNode != null) {
result.setFocused(inputFocusedSemanticsNode.id == virtualViewId);
}
if (accessibilityFocusedSemanticsNode != null) {
result.setAccessibilityFocused(accessibilityFocusedSemanticsNode.id == virtualViewId);
}
if (semanticsNode.hasFlag(Flag.IS_TEXT_FIELD)) {
result.setPassword(semanticsNode.hasFlag(Flag.IS_OBSCURED));
if (!semanticsNode.hasFlag(Flag.IS_READ_ONLY)) {
result.setClassName("android.widget.EditText");
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
result.setEditable(!semanticsNode.hasFlag(Flag.IS_READ_ONLY));
if (semanticsNode.textSelectionBase != -1 && semanticsNode.textSelectionExtent != -1) {
result.setTextSelection(
semanticsNode.textSelectionBase, semanticsNode.textSelectionExtent);
}
// Text fields will always be created as a live region when they have input focus,
// so that updates to the label trigger polite announcements. This makes it easy to
// follow a11y guidelines for text fields on Android.
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2
&& accessibilityFocusedSemanticsNode != null
&& accessibilityFocusedSemanticsNode.id == virtualViewId) {
result.setLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE);
}
}
// Cursor movements
int granularities = 0;
if (semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) {
result.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER;
}
if (semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) {
result.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER;
}
if (semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_WORD)) {
result.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD;
}
if (semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_WORD)) {
result.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD;
}
result.setMovementGranularities(granularities);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& semanticsNode.maxValueLength >= 0) {
// Account for the fact that Flutter is counting Unicode scalar values and Android
// is counting UTF16 words.
final int length = semanticsNode.value == null ? 0 : semanticsNode.value.length();
int a = length - semanticsNode.currentValueLength + semanticsNode.maxValueLength;
result.setMaxTextLength(
length - semanticsNode.currentValueLength + semanticsNode.maxValueLength);
}
}
// These are non-ops on older devices. Attempting to interact with the text will cause Talkback
// to read the contents of the text box instead.
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) {
if (semanticsNode.hasAction(Action.SET_SELECTION)) {
result.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION);
}
if (semanticsNode.hasAction(Action.COPY)) {
result.addAction(AccessibilityNodeInfo.ACTION_COPY);
}
if (semanticsNode.hasAction(Action.CUT)) {
result.addAction(AccessibilityNodeInfo.ACTION_CUT);
}
if (semanticsNode.hasAction(Action.PASTE)) {
result.addAction(AccessibilityNodeInfo.ACTION_PASTE);
}
}
// Set text API isn't available until API 21.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (semanticsNode.hasAction(Action.SET_TEXT)) {
result.addAction(AccessibilityNodeInfo.ACTION_SET_TEXT);
}
}
if (semanticsNode.hasFlag(Flag.IS_BUTTON) || semanticsNode.hasFlag(Flag.IS_LINK)) {
result.setClassName("android.widget.Button");
}
if (semanticsNode.hasFlag(Flag.IS_IMAGE)) {
result.setClassName("android.widget.ImageView");
// TODO(jonahwilliams): Figure out a way conform to the expected id from TalkBack's
// CustomLabelManager. talkback/src/main/java/labeling/CustomLabelManager.java#L525
}
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2
&& semanticsNode.hasAction(Action.DISMISS)) {
result.setDismissable(true);
result.addAction(AccessibilityNodeInfo.ACTION_DISMISS);
}
if (semanticsNode.parent != null) {
if (BuildConfig.DEBUG && semanticsNode.id <= ROOT_NODE_ID) {
Log.e(TAG, "Semantics node id is not > ROOT_NODE_ID.");
}
result.setParent(rootAccessibilityView, semanticsNode.parent.id);
} else {
if (BuildConfig.DEBUG && semanticsNode.id != ROOT_NODE_ID) {
Log.e(TAG, "Semantics node id does not equal ROOT_NODE_ID.");
}
result.setParent(rootAccessibilityView);
}
if (semanticsNode.previousNodeId != -1
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
result.setTraversalAfter(rootAccessibilityView, semanticsNode.previousNodeId);
}
Rect bounds = semanticsNode.getGlobalRect();
if (semanticsNode.parent != null) {
Rect parentBounds = semanticsNode.parent.getGlobalRect();
Rect boundsInParent = new Rect(bounds);
boundsInParent.offset(-parentBounds.left, -parentBounds.top);
result.setBoundsInParent(boundsInParent);
} else {
result.setBoundsInParent(bounds);
}
final Rect boundsInScreen = getBoundsInScreen(bounds);
result.setBoundsInScreen(boundsInScreen);
result.setVisibleToUser(true);
result.setEnabled(
!semanticsNode.hasFlag(Flag.HAS_ENABLED_STATE) || semanticsNode.hasFlag(Flag.IS_ENABLED));
if (semanticsNode.hasAction(Action.TAP)) {
if (Build.VERSION.SDK_INT >= 21 && semanticsNode.onTapOverride != null) {
result.addAction(
new AccessibilityNodeInfo.AccessibilityAction(
AccessibilityNodeInfo.ACTION_CLICK, semanticsNode.onTapOverride.hint));
result.setClickable(true);
} else {
result.addAction(AccessibilityNodeInfo.ACTION_CLICK);
result.setClickable(true);
}
}
if (semanticsNode.hasAction(Action.LONG_PRESS)) {
if (Build.VERSION.SDK_INT >= 21 && semanticsNode.onLongPressOverride != null) {
result.addAction(
new AccessibilityNodeInfo.AccessibilityAction(
AccessibilityNodeInfo.ACTION_LONG_CLICK, semanticsNode.onLongPressOverride.hint));
result.setLongClickable(true);
} else {
result.addAction(AccessibilityNodeInfo.ACTION_LONG_CLICK);
result.setLongClickable(true);
}
}
if (semanticsNode.hasAction(Action.SCROLL_LEFT)
|| semanticsNode.hasAction(Action.SCROLL_UP)
|| semanticsNode.hasAction(Action.SCROLL_RIGHT)
|| semanticsNode.hasAction(Action.SCROLL_DOWN)) {
result.setScrollable(true);
// This tells Android's a11y to send scroll events when reaching the end of
// the visible viewport of a scrollable, unless the node itself does not
// allow implicit scrolling - then we leave the className as view.View.
//
// We should prefer setCollectionInfo to the class names, as this way we get "In List"
// and "Out of list" announcements. But we don't always know the counts, so we
// can fallback to the generic scroll view class names.
//
// On older APIs, we always fall back to the generic scroll view class names here.
//
// TODO(dnfield): We should add semantics properties for rows and columns in 2 dimensional
// lists, e.g.
// GridView. Right now, we're only supporting ListViews and only if they have scroll
// children.
if (semanticsNode.hasFlag(Flag.HAS_IMPLICIT_SCROLLING)) {
if (semanticsNode.hasAction(Action.SCROLL_LEFT)
|| semanticsNode.hasAction(Action.SCROLL_RIGHT)) {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT
&& shouldSetCollectionInfo(semanticsNode)) {
result.setCollectionInfo(
AccessibilityNodeInfo.CollectionInfo.obtain(
0, // rows
semanticsNode.scrollChildren, // columns
false // hierarchical
));
} else {
result.setClassName("android.widget.HorizontalScrollView");
}
} else {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2
&& shouldSetCollectionInfo(semanticsNode)) {
result.setCollectionInfo(
AccessibilityNodeInfo.CollectionInfo.obtain(
semanticsNode.scrollChildren, // rows
0, // columns
false // hierarchical
));
} else {
result.setClassName("android.widget.ScrollView");
}
}
}
// TODO(ianh): Once we're on SDK v23+, call addAction to
// expose AccessibilityAction.ACTION_SCROLL_LEFT, _RIGHT,
// _UP, and _DOWN when appropriate.
if (semanticsNode.hasAction(Action.SCROLL_LEFT)
|| semanticsNode.hasAction(Action.SCROLL_UP)) {
result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
}
if (semanticsNode.hasAction(Action.SCROLL_RIGHT)
|| semanticsNode.hasAction(Action.SCROLL_DOWN)) {
result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
}
}
if (semanticsNode.hasAction(Action.INCREASE) || semanticsNode.hasAction(Action.DECREASE)) {
// TODO(jonahwilliams): support AccessibilityAction.ACTION_SET_PROGRESS once SDK is
// updated.
result.setClassName("android.widget.SeekBar");
if (semanticsNode.hasAction(Action.INCREASE)) {
result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
}
if (semanticsNode.hasAction(Action.DECREASE)) {
result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
}
}
if (semanticsNode.hasFlag(Flag.IS_LIVE_REGION)
&& Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) {
result.setLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE);
}
// Scopes routes are not focusable, only need to set the content
// for non-scopes-routes semantics nodes.
if (semanticsNode.hasFlag(Flag.IS_TEXT_FIELD)) {
result.setText(semanticsNode.getValueLabelHint());
} else if (!semanticsNode.hasFlag(Flag.SCOPES_ROUTE)) {
CharSequence content = semanticsNode.getValueLabelHint();
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
if (semanticsNode.tooltip != null) {
// For backward compatibility with Flutter SDK before Android API
// level 28, the tooltip is appended at the end of content description.
content = content != null ? content : "";
content = content + "\n" + semanticsNode.tooltip;
}
}
if (content != null) {
result.setContentDescription(content);
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (semanticsNode.tooltip != null) {
result.setTooltipText(semanticsNode.tooltip);
}
}
boolean hasCheckedState = semanticsNode.hasFlag(Flag.HAS_CHECKED_STATE);
boolean hasToggledState = semanticsNode.hasFlag(Flag.HAS_TOGGLED_STATE);
if (BuildConfig.DEBUG && (hasCheckedState && hasToggledState)) {
Log.e(TAG, "Expected semanticsNode to have checked state and toggled state.");
}
result.setCheckable(hasCheckedState || hasToggledState);
if (hasCheckedState) {
result.setChecked(semanticsNode.hasFlag(Flag.IS_CHECKED));
if (semanticsNode.hasFlag(Flag.IS_IN_MUTUALLY_EXCLUSIVE_GROUP)) {
result.setClassName("android.widget.RadioButton");
} else {
result.setClassName("android.widget.CheckBox");
}
} else if (hasToggledState) {
result.setChecked(semanticsNode.hasFlag(Flag.IS_TOGGLED));
result.setClassName("android.widget.Switch");
}
result.setSelected(semanticsNode.hasFlag(Flag.IS_SELECTED));
// Heading support
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
result.setHeading(semanticsNode.hasFlag(Flag.IS_HEADER));
}
// Accessibility Focus
if (accessibilityFocusedSemanticsNode != null
&& accessibilityFocusedSemanticsNode.id == virtualViewId) {
result.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
} else {
result.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS);
}
// Actions on the local context menu
if (Build.VERSION.SDK_INT >= 21) {
if (semanticsNode.customAccessibilityActions != null) {
for (CustomAccessibilityAction action : semanticsNode.customAccessibilityActions) {
result.addAction(
new AccessibilityNodeInfo.AccessibilityAction(action.resourceId, action.label));
}
}
}
for (SemanticsNode child : semanticsNode.childrenInTraversalOrder) {
if (child.hasFlag(Flag.IS_HIDDEN)) {
continue;
}
if (child.platformViewId != -1) {
View embeddedView =
platformViewsAccessibilityDelegate.getPlatformViewById(child.platformViewId);
// Add the embedded view as a child of the current accessibility node if it's using
// hybrid composition.
//
// In this case, the view is in the Activity's view hierarchy, so it doesn't need to be
// mirrored.
//
// See the case above for how virtual displays are handled.
if (!platformViewsAccessibilityDelegate.usesVirtualDisplay(child.platformViewId)) {
result.addChild(embeddedView);
continue;
}
}
result.addChild(rootAccessibilityView, child.id);
}
return result;
}