// Copyright 2013 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package io.flutter.view; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.Activity; import android.content.ContentResolver; import android.content.Context; import android.database.ContentObserver; import android.graphics.Rect; import android.net.Uri; import android.opengl.Matrix; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.provider.Settings; import android.text.SpannableString; import android.text.TextUtils; import android.text.style.LocaleSpan; import android.text.style.TtsSpan; import android.view.MotionEvent; import android.view.View; import android.view.WindowInsets; import android.view.WindowManager; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeInfo; import android.view.accessibility.AccessibilityNodeProvider; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; import io.flutter.BuildConfig; import io.flutter.Log; import io.flutter.embedding.engine.systemchannels.AccessibilityChannel; import io.flutter.plugin.platform.PlatformViewsAccessibilityDelegate; import io.flutter.util.Predicate; import io.flutter.util.ViewUtils; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.Charset; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Bridge between Android's OS accessibility system and Flutter's accessibility system. * *
An {@code AccessibilityBridge} requires: * *
Do not use this instance after invoking {@code release}. The behavior of any method invoked * on this {@code AccessibilityBridge} after invoking {@code release()} is undefined. */ public void release() { isReleased = true; // platformViewsAccessibilityDelegate should be @NonNull once the plumbing // for io.flutter.embedding.engine.android.FlutterView is done. // TODO(mattcarrol): Remove the null check once the plumbing is done. // https://github.com/flutter/flutter/issues/29618 if (platformViewsAccessibilityDelegate != null) { platformViewsAccessibilityDelegate.detachAccessibiltyBridge(); } setOnAccessibilityChangeListener(null); accessibilityManager.removeAccessibilityStateChangeListener(accessibilityStateChangeListener); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { accessibilityManager.removeTouchExplorationStateChangeListener( touchExplorationStateChangeListener); } contentResolver.unregisterContentObserver(animationScaleObserver); accessibilityChannel.setAccessibilityMessageHandler(null); } /** Returns true if the Android OS currently has accessibility enabled, false otherwise. */ public boolean isAccessibilityEnabled() { return accessibilityManager.isEnabled(); } /** Returns true if the Android OS currently has touch exploration enabled, false otherwise. */ public boolean isTouchExplorationEnabled() { return accessibilityManager.isTouchExplorationEnabled(); } /** * Sets a listener on this {@code AccessibilityBridge}, which is notified whenever accessibility * activation, or touch exploration activation changes. */ public void setOnAccessibilityChangeListener(@Nullable OnAccessibilityChangeListener listener) { this.onAccessibilityChangeListener = listener; } /** Sends the current value of {@link #accessibilityFeatureFlags} to Flutter. */ private void sendLatestAccessibilityFlagsToFlutter() { accessibilityChannel.setAccessibilityFeatures(accessibilityFeatureFlags); } private boolean shouldSetCollectionInfo(final SemanticsNode semanticsNode) { // TalkBack expects a number of rows and/or columns greater than 0 to announce // in list and out of list. For an infinite or growing list, you have to // specify something > 0 to get "in list" announcements. // TalkBack will also only track one list at a time, so we only want to set this // for a list that contains the current a11y focused semanticsNode - otherwise, if there // are two lists or nested lists, we may end up with announcements for only the last // one that is currently available in the semantics tree. However, we also want // to set it if we're exiting a list to a non-list, so that we can get the "out of list" // announcement when A11y focus moves out of a list and not into another list. return semanticsNode.scrollChildren > 0 && (SemanticsNode.nullableHasAncestor( accessibilityFocusedSemanticsNode, o -> o == semanticsNode) || !SemanticsNode.nullableHasAncestor( accessibilityFocusedSemanticsNode, o -> o.hasFlag(Flag.HAS_IMPLICIT_SCROLLING))); } @VisibleForTesting public AccessibilityNodeInfo obtainAccessibilityNodeInfo(View rootView, int virtualViewId) { return AccessibilityNodeInfo.obtain(rootView, virtualViewId); } /** * Returns {@link AccessibilityNodeInfo} for the view corresponding to the given {@code * virtualViewId}. * *
This method is invoked by Android's accessibility system when Android needs accessibility * info for a given view. * *
When a {@code virtualViewId} of {@link View#NO_ID} is requested, accessibility node info is * returned for our {@link #rootAccessibilityView}. Otherwise, Flutter's semantics tree, * represented by {@link #flutterSemanticsTree}, is searched for a {@link SemanticsNode} with the * given {@code virtualViewId}. If no such {@link SemanticsNode} is found, then this method * returns null. If the desired {@link SemanticsNode} is found, then an {@link * AccessibilityNodeInfo} is obtained from the {@link #rootAccessibilityView}, filled with * appropriate info, and then returned. * *
Depending on the type of Flutter {@code SemanticsNode} that is requested, the returned * {@link AccessibilityNodeInfo} pretends that the {@code SemanticsNode} in question comes from a * specialize Android view, e.g., {@link Flag#IS_TEXT_FIELD} maps to {@code * android.widget.EditText}, {@link Flag#IS_BUTTON} maps to {@code android.widget.Button}, and * {@link Flag#IS_IMAGE} maps to {@code android.widget.ImageView}. In the case that no specialized * view applies, the returned {@link AccessibilityNodeInfo} pretends that it represents a {@code * android.view.View}. */ @Override @SuppressWarnings("deprecation") // Suppressing Lint warning for new API, as we are version guarding all calls to newer APIs @SuppressLint("NewApi") 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); } if (semanticsNode.hasFlag(Flag.IS_TEXT_FIELD)) { result.setText(semanticsNode.getValueLabelHint()); } else if (!semanticsNode.hasFlag(Flag.SCOPES_ROUTE)) { CharSequence content = semanticsNode.getValueLabelHint(); if (content != null) { result.setContentDescription(content); } } 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; } /** * Get the bounds in screen with root FlutterView's offset. * * @param bounds the bounds in FlutterView * @return the bounds with offset */ private Rect getBoundsInScreen(Rect bounds) { Rect boundsInScreen = new Rect(bounds); int[] locationOnScreen = new int[2]; rootAccessibilityView.getLocationOnScreen(locationOnScreen); boundsInScreen.offset(locationOnScreen[0], locationOnScreen[1]); return boundsInScreen; } /** * Instructs the view represented by {@code virtualViewId} to carry out the desired {@code * accessibilityAction}, perhaps configured by additional {@code arguments}. * *
This method is invoked by Android's accessibility system. This method returns true if the * desired {@code SemanticsNode} was found and was capable of performing the desired action, false * otherwise. * *
In a traditional Android app, the given view ID refers to a {@link View} within an Android
* {@link View} hierarchy. Flutter does not have an Android {@link View} hierarchy, therefore the
* given view ID is a {@code virtualViewId} that refers to a {@code SemanticsNode} within a
* Flutter app. The given arguments of this method are forwarded from Android to Flutter.
*/
@Override
public boolean performAction(
int virtualViewId, int accessibilityAction, @Nullable Bundle arguments) {
if (virtualViewId >= MIN_ENGINE_GENERATED_NODE_ID) {
// The node is in the engine generated range, and is handled by the accessibility view
// embedder.
boolean didPerform =
accessibilityViewEmbedder.performAction(virtualViewId, accessibilityAction, arguments);
if (didPerform
&& accessibilityAction == AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS) {
embeddedAccessibilityFocusedNodeId = null;
}
return didPerform;
}
SemanticsNode semanticsNode = flutterSemanticsTree.get(virtualViewId);
if (semanticsNode == null) {
return false;
}
switch (accessibilityAction) {
case AccessibilityNodeInfo.ACTION_CLICK:
{
// Note: TalkBack prior to Oreo doesn't use this handler and instead simulates a
// click event at the center of the SemanticsNode. Other a11y services might go
// through this handler though.
accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.TAP);
return true;
}
case AccessibilityNodeInfo.ACTION_LONG_CLICK:
{
// Note: TalkBack doesn't use this handler and instead simulates a long click event
// at the center of the SemanticsNode. Other a11y services might go through this
// handler though.
accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.LONG_PRESS);
return true;
}
case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
{
if (semanticsNode.hasAction(Action.SCROLL_UP)) {
accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SCROLL_UP);
} else if (semanticsNode.hasAction(Action.SCROLL_LEFT)) {
// TODO(ianh): bidi support using textDirection
accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SCROLL_LEFT);
} else if (semanticsNode.hasAction(Action.INCREASE)) {
semanticsNode.value = semanticsNode.increasedValue;
semanticsNode.valueAttributes = semanticsNode.increasedValueAttributes;
// Event causes Android to read out the updated value.
sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED);
accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.INCREASE);
} else {
return false;
}
return true;
}
case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
{
if (semanticsNode.hasAction(Action.SCROLL_DOWN)) {
accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SCROLL_DOWN);
} else if (semanticsNode.hasAction(Action.SCROLL_RIGHT)) {
// TODO(ianh): bidi support using textDirection
accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SCROLL_RIGHT);
} else if (semanticsNode.hasAction(Action.DECREASE)) {
semanticsNode.value = semanticsNode.decreasedValue;
semanticsNode.valueAttributes = semanticsNode.decreasedValueAttributes;
// Event causes Android to read out the updated value.
sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED);
accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.DECREASE);
} else {
return false;
}
return true;
}
case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
{
// Text selection APIs aren't available until API 18. We can't handle the case here so
// return false
// instead. It's extremely unlikely that this case would ever be triggered in the first
// place in API <
// 18.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
return false;
}
return performCursorMoveAction(semanticsNode, virtualViewId, arguments, false);
}
case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
{
// Text selection APIs aren't available until API 18. We can't handle the case here so
// return false
// instead. It's extremely unlikely that this case would ever be triggered in the first
// place in API <
// 18.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
return false;
}
return performCursorMoveAction(semanticsNode, virtualViewId, arguments, true);
}
case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
{
accessibilityChannel.dispatchSemanticsAction(
virtualViewId, Action.DID_LOSE_ACCESSIBILITY_FOCUS);
sendAccessibilityEvent(
virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
accessibilityFocusedSemanticsNode = null;
embeddedAccessibilityFocusedNodeId = null;
return true;
}
case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS:
{
accessibilityChannel.dispatchSemanticsAction(
virtualViewId, Action.DID_GAIN_ACCESSIBILITY_FOCUS);
sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
if (accessibilityFocusedSemanticsNode == null) {
// When Android focuses a node, it doesn't invalidate the view.
// (It does when it sends ACTION_CLEAR_ACCESSIBILITY_FOCUS, so
// we only have to worry about this when the focused node is null.)
rootAccessibilityView.invalidate();
}
accessibilityFocusedSemanticsNode = semanticsNode;
if (semanticsNode.hasAction(Action.INCREASE)
|| semanticsNode.hasAction(Action.DECREASE)) {
// SeekBars only announce themselves after this event.
sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED);
}
return true;
}
case ACTION_SHOW_ON_SCREEN:
{
accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SHOW_ON_SCREEN);
return true;
}
case AccessibilityNodeInfo.ACTION_SET_SELECTION:
{
// Text selection APIs aren't available until API 18. We can't handle the case here so
// return false
// instead. It's extremely unlikely that this case would ever be triggered in the first
// place in API <
// 18.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
return false;
}
final Map This method is invoked by Android's accessibility system.
*
* Flutter does not have an Android {@link View} hierarchy. Therefore, Flutter conceptually
* handles this request by searching its semantics tree for the given {@code focus}, represented
* by {@link #flutterSemanticsTree}. In practice, this {@code AccessibilityBridge} always caches
* any active {@link #accessibilityFocusedSemanticsNode} and {@link #inputFocusedSemanticsNode}.
* Therefore, no searching is necessary. This method directly inspects the given {@code focus}
* type to return one of the cached nodes, null if the cached node is null, or null if a different
* {@code focus} type is requested.
*/
@Override
public AccessibilityNodeInfo findFocus(int focus) {
switch (focus) {
case AccessibilityNodeInfo.FOCUS_INPUT:
{
if (inputFocusedSemanticsNode != null) {
return createAccessibilityNodeInfo(inputFocusedSemanticsNode.id);
}
if (embeddedInputFocusedNodeId != null) {
return createAccessibilityNodeInfo(embeddedInputFocusedNodeId);
}
}
// Fall through to check FOCUS_ACCESSIBILITY
case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY:
{
if (accessibilityFocusedSemanticsNode != null) {
return createAccessibilityNodeInfo(accessibilityFocusedSemanticsNode.id);
}
if (embeddedAccessibilityFocusedNodeId != null) {
return createAccessibilityNodeInfo(embeddedAccessibilityFocusedNodeId);
}
}
}
return null;
}
/** Returns the {@link SemanticsNode} at the root of Flutter's semantics tree. */
private SemanticsNode getRootSemanticsNode() {
if (BuildConfig.DEBUG && !flutterSemanticsTree.containsKey(0)) {
Log.e(TAG, "Attempted to getRootSemanticsNode without a root semantics node.");
}
return flutterSemanticsTree.get(0);
}
/**
* Returns an existing {@link SemanticsNode} with the given {@code id}, if it exists within {@link
* #flutterSemanticsTree}, or creates and returns a new {@link SemanticsNode} with the given
* {@code id}, adding the new {@link SemanticsNode} to the {@link #flutterSemanticsTree}.
*
* This method should only be invoked as a result of receiving new information from Flutter.
* The {@link #flutterSemanticsTree} is an Android cache of the last known state of a Flutter
* app's semantics tree, therefore, invoking this method in any other situation will result in a
* corrupt cache of Flutter's semantics tree.
*/
private SemanticsNode getOrCreateSemanticsNode(int id) {
SemanticsNode semanticsNode = flutterSemanticsTree.get(id);
if (semanticsNode == null) {
semanticsNode = new SemanticsNode(this);
semanticsNode.id = id;
flutterSemanticsTree.put(id, semanticsNode);
}
return semanticsNode;
}
/**
* Returns an existing {@link CustomAccessibilityAction} with the given {@code id}, if it exists
* within {@link #customAccessibilityActions}, or creates and returns a new {@link
* CustomAccessibilityAction} with the given {@code id}, adding the new {@link
* CustomAccessibilityAction} to the {@link #customAccessibilityActions}.
*
* This method should only be invoked as a result of receiving new information from Flutter.
* The {@link #customAccessibilityActions} is an Android cache of the last known state of a
* Flutter app's registered custom accessibility actions, therefore, invoking this method in any
* other situation will result in a corrupt cache of Flutter's accessibility actions.
*/
private CustomAccessibilityAction getOrCreateAccessibilityAction(int id) {
CustomAccessibilityAction action = customAccessibilityActions.get(id);
if (action == null) {
action = new CustomAccessibilityAction();
action.id = id;
action.resourceId = id + FIRST_RESOURCE_ID;
customAccessibilityActions.put(id, action);
}
return action;
}
/**
* A hover {@link MotionEvent} has occurred in the {@code View} that corresponds to this {@code
* AccessibilityBridge}.
*
* This method returns true if Flutter's accessibility system handled the hover event, false
* otherwise.
*
* This method should be invoked from the corresponding {@code View}'s {@link
* View#onHoverEvent(MotionEvent)}.
*/
public boolean onAccessibilityHoverEvent(MotionEvent event) {
if (!accessibilityManager.isTouchExplorationEnabled()) {
return false;
}
if (flutterSemanticsTree.isEmpty()) {
return false;
}
SemanticsNode semanticsNodeUnderCursor =
getRootSemanticsNode().hitTest(new float[] {event.getX(), event.getY(), 0, 1});
// semanticsNodeUnderCursor can be null when hovering over non-flutter UI such as
// the Android navigation bar due to hitTest() bounds checking.
if (semanticsNodeUnderCursor != null && semanticsNodeUnderCursor.platformViewId != -1) {
return accessibilityViewEmbedder.onAccessibilityHoverEvent(
semanticsNodeUnderCursor.id, event);
}
if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER
|| event.getAction() == MotionEvent.ACTION_HOVER_MOVE) {
handleTouchExploration(event.getX(), event.getY());
} else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) {
onTouchExplorationExit();
} else {
Log.d("flutter", "unexpected accessibility hover event: " + event);
return false;
}
return true;
}
/**
* This method should be invoked when a hover interaction has the cursor move off of a {@code
* SemanticsNode}.
*
* This method informs the Android accessibility system that a {@link
* AccessibilityEvent#TYPE_VIEW_HOVER_EXIT} has occurred.
*/
private void onTouchExplorationExit() {
if (hoveredObject != null) {
sendAccessibilityEvent(hoveredObject.id, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT);
hoveredObject = null;
}
}
/**
* This method should be invoked when a new hover interaction begins with a {@code SemanticsNode},
* or when an existing hover interaction sees a movement of the cursor.
*
* This method checks to see if the cursor has moved from one {@code SemanticsNode} to another.
* If it has, this method informs the Android accessibility system of the change by first sending
* a {@link AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} event for the new hover node, followed by a
* {@link AccessibilityEvent#TYPE_VIEW_HOVER_EXIT} event for the old hover node.
*/
private void handleTouchExploration(float x, float y) {
if (flutterSemanticsTree.isEmpty()) {
return;
}
SemanticsNode semanticsNodeUnderCursor =
getRootSemanticsNode().hitTest(new float[] {x, y, 0, 1});
if (semanticsNodeUnderCursor != hoveredObject) {
// sending ENTER before EXIT is how Android wants it
if (semanticsNodeUnderCursor != null) {
sendAccessibilityEvent(
semanticsNodeUnderCursor.id, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER);
}
if (hoveredObject != null) {
sendAccessibilityEvent(hoveredObject.id, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT);
}
hoveredObject = semanticsNodeUnderCursor;
}
}
/**
* Updates the Android cache of Flutter's currently registered custom accessibility actions.
*
* The buffer received here is encoded by PlatformViewAndroid::UpdateSemantics, and the decode
* logic here must be kept in sync with that method's encoding logic.
*/
// TODO(mattcarroll): Consider introducing ability to delete custom actions because they can
// probably come and go in Flutter, so we may want to reflect that here in
// the Android cache as well.
void updateCustomAccessibilityActions(@NonNull ByteBuffer buffer, @NonNull String[] strings) {
while (buffer.hasRemaining()) {
int id = buffer.getInt();
CustomAccessibilityAction action = getOrCreateAccessibilityAction(id);
action.overrideId = buffer.getInt();
int stringIndex = buffer.getInt();
action.label = stringIndex == -1 ? null : strings[stringIndex];
stringIndex = buffer.getInt();
action.hint = stringIndex == -1 ? null : strings[stringIndex];
}
}
/**
* Updates {@link #flutterSemanticsTree} to reflect the latest state of Flutter's semantics tree.
*
* The latest state of Flutter's semantics tree is encoded in the given {@code buffer}. The
* buffer is encoded by PlatformViewAndroid::UpdateSemantics, and the decode logic must be kept in
* sync with that method's encoding logic.
*/
void updateSemantics(
@NonNull ByteBuffer buffer,
@NonNull String[] strings,
@NonNull ByteBuffer[] stringAttributeArgs) {
ArrayList The given {@code viewId} may either belong to {@link #rootAccessibilityView}, or any Flutter
* {@link SemanticsNode}.
*/
private void sendAccessibilityEvent(int viewId, int eventType) {
if (!accessibilityManager.isEnabled()) {
return;
}
sendAccessibilityEvent(obtainAccessibilityEvent(viewId, eventType));
}
/**
* Sends the given {@link AccessibilityEvent} to Android's accessibility system for a given
* Flutter {@link SemanticsNode}.
*
* This method should only be called for a Flutter {@link SemanticsNode}, not a traditional
* Android {@code View}, i.e., {@link #rootAccessibilityView}.
*/
private void sendAccessibilityEvent(@NonNull AccessibilityEvent event) {
if (!accessibilityManager.isEnabled()) {
return;
}
// See
// https://developer.android.com/reference/android/view/View.html#sendAccessibilityEvent(int)
// We just want the final part at this point, since the event parameter
// has already been correctly populated.
rootAccessibilityView.getParent().requestSendAccessibilityEvent(rootAccessibilityView, event);
}
/**
* Informs the TalkBack user about window name changes.
*
* This method sets accessibility panel title if the API level >= 28, otherwise, it creates a
* {@link AccessibilityEvent#TYPE_WINDOW_STATE_CHANGED} and sends the event to Android's
* accessibility system. In both cases, TalkBack announces the label of the route and re-addjusts
* the accessibility focus.
*
* The given {@code route} should be a {@link SemanticsNode} that represents a navigation route
* in the Flutter app.
*/
private void onWindowNameChange(@NonNull SemanticsNode route) {
String routeName = route.getRouteName();
if (routeName == null) {
// The routeName will be null when there is no semantics node that represnets namesRoute in
// the scopeRoute. The TYPE_WINDOW_STATE_CHANGED only works the route name is not null and not
// empty. Gives it a whitespace will make it focus the first semantics node without
// pronouncing any word.
//
// The other way to trigger a focus change is to send a TYPE_VIEW_FOCUSED to the
// rootAccessibilityView. However, it is less predictable which semantics node it will focus
// next.
routeName = " ";
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setAccessibilityPaneTitle(routeName);
} else {
AccessibilityEvent event =
obtainAccessibilityEvent(route.id, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
event.getText().add(routeName);
sendAccessibilityEvent(event);
}
}
@TargetApi(28)
@RequiresApi(28)
private void setAccessibilityPaneTitle(String title) {
rootAccessibilityView.setAccessibilityPaneTitle(title);
}
/**
* Creates a {@link AccessibilityEvent#TYPE_WINDOW_CONTENT_CHANGED} and sends the event to
* Android's accessibility system.
*
* It sets the content change types to {@link AccessibilityEvent#CONTENT_CHANGE_TYPE_SUBTREE}
* when supported by the API level.
*
* The given {@code virtualViewId} should be a {@link SemanticsNode} below which the content
* has changed.
*/
private void sendWindowContentChangeEvent(int virtualViewId) {
AccessibilityEvent event =
obtainAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
event.setContentChangeTypes(AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE);
}
sendAccessibilityEvent(event);
}
/**
* Factory method that creates a new {@link AccessibilityEvent} that is configured to represent
* the Flutter {@link SemanticsNode} represented by the given {@code virtualViewId}, categorized
* as the given {@code eventType}.
*
* This method should *only* be called for Flutter {@link SemanticsNode}s. It should *not* be
* invoked to create an {@link AccessibilityEvent} for the {@link #rootAccessibilityView}.
*/
private AccessibilityEvent obtainAccessibilityEvent(int virtualViewId, int eventType) {
AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
event.setPackageName(rootAccessibilityView.getContext().getPackageName());
event.setSource(rootAccessibilityView, virtualViewId);
return event;
}
/**
* Reads the {@code layoutInDisplayCutoutMode} value from the window attribute and returns whether
* a left cutout inset is required.
*
* The {@code layoutInDisplayCutoutMode} is added after API level 28.
*/
@TargetApi(28)
@RequiresApi(28)
private boolean doesLayoutInDisplayCutoutModeRequireLeftInset() {
Context context = rootAccessibilityView.getContext();
Activity activity = ViewUtils.getActivity(context);
if (activity == null || activity.getWindow() == null) {
// The activity is not visible, it does not matter whether to apply left inset
// or not.
return false;
}
int layoutInDisplayCutoutMode = activity.getWindow().getAttributes().layoutInDisplayCutoutMode;
return layoutInDisplayCutoutMode
== WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
|| layoutInDisplayCutoutMode
== WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT;
}
/**
* Hook called just before a {@link SemanticsNode} is removed from the Android cache of Flutter's
* semantics tree.
*/
@TargetApi(19)
@RequiresApi(19)
private void willRemoveSemanticsNode(SemanticsNode semanticsNodeToBeRemoved) {
if (BuildConfig.DEBUG) {
if (!flutterSemanticsTree.containsKey(semanticsNodeToBeRemoved.id)) {
Log.e(TAG, "Attempted to remove a node that is not in the tree.");
}
if (flutterSemanticsTree.get(semanticsNodeToBeRemoved.id) != semanticsNodeToBeRemoved) {
Log.e(TAG, "Flutter semantics tree failed to get expected node when searching by id.");
}
}
// TODO(mattcarroll): should parent be set to "null" here? Changing the parent seems like the
// behavior of a method called "removeSemanticsNode()". The same is true
// for null'ing accessibilityFocusedSemanticsNode, inputFocusedSemanticsNode,
// and hoveredObject. Is this a hook method or a command?
semanticsNodeToBeRemoved.parent = null;
if (semanticsNodeToBeRemoved.platformViewId != -1
&& embeddedAccessibilityFocusedNodeId != null
&& accessibilityViewEmbedder.platformViewOfNode(embeddedAccessibilityFocusedNodeId)
== platformViewsAccessibilityDelegate.getPlatformViewById(
semanticsNodeToBeRemoved.platformViewId)) {
// If the currently focused a11y node is within a platform view that is
// getting removed: clear it's a11y focus.
sendAccessibilityEvent(
embeddedAccessibilityFocusedNodeId,
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
embeddedAccessibilityFocusedNodeId = null;
}
if (semanticsNodeToBeRemoved.platformViewId != -1
&& !platformViewsAccessibilityDelegate.usesVirtualDisplay(
semanticsNodeToBeRemoved.platformViewId)) {
View embeddedView =
platformViewsAccessibilityDelegate.getPlatformViewById(
semanticsNodeToBeRemoved.platformViewId);
if (embeddedView != null) {
embeddedView.setImportantForAccessibility(
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
}
}
if (accessibilityFocusedSemanticsNode == semanticsNodeToBeRemoved) {
sendAccessibilityEvent(
accessibilityFocusedSemanticsNode.id,
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
accessibilityFocusedSemanticsNode = null;
}
if (inputFocusedSemanticsNode == semanticsNodeToBeRemoved) {
inputFocusedSemanticsNode = null;
}
if (hoveredObject == semanticsNodeToBeRemoved) {
hoveredObject = null;
}
}
/**
* Resets the {@code AccessibilityBridge}:
*
* Flutter and Android support a number of built-in accessibility actions. However, these
* predefined actions are not always sufficient for a desired interaction. Android facilitates
* custom accessibility actions,
* https://developer.android.com/reference/android/view/accessibility/AccessibilityNodeInfo.AccessibilityAction.
* Flutter supports custom accessibility actions via {@code customSemanticsActions} within a
* {@code Semantics} widget, https://api.flutter.dev/flutter/widgets/Semantics-class.html.
*
* See the Android documentation for custom accessibility actions:
* https://developer.android.com/reference/android/view/accessibility/AccessibilityNodeInfo.AccessibilityAction
*
* See the Flutter documentation for the Semantics widget:
* https://api.flutter.dev/flutter/widgets/Semantics-class.html
*/
private static class CustomAccessibilityAction {
CustomAccessibilityAction() {}
// The ID of the custom action plus a minimum value so that the identifier
// does not collide with existing Android accessibility actions. This ID
// represents and Android resource ID, not a Flutter ID.
private int resourceId = -1;
// The Flutter ID of this custom accessibility action. See Flutter's Semantics widget for
// custom accessibility action definitions:
// https://api.flutter.dev/flutter/widgets/Semantics-class.html
private int id = -1;
// The ID of the standard Flutter accessibility action that this {@code
// CustomAccessibilityAction}
// overrides with a custom {@code label} and/or {@code hint}.
private int overrideId = -1;
// The user presented value which is displayed in the local context menu.
private String label;
// The text used in overridden standard actions.
private String hint;
}
// When adding a new StringAttributeType, the classes in these file must be
// updated as well.
// * engine/src/flutter/lib/ui/semantics.dart
// * engine/src/flutter/lib/web_ui/lib/src/ui/semantics.dart
// * engine/src/flutter/lib/ui/semantics/string_attribute.h
private enum StringAttributeType {
SPELLOUT,
LOCALE,
}
private static class StringAttribute {
int start;
int end;
StringAttributeType type;
}
private static class SpellOutStringAttribute extends StringAttribute {}
private static class LocaleStringAttribute extends StringAttribute {
String locale;
}
/**
* Flutter {@code SemanticsNode} represented in Java/Android.
*
* Flutter maintains a semantics tree that is controlled by, but is independent of Flutter's
* element tree, i.e., widgets/elements/render objects. Flutter's semantics tree must be cached on
* the Android side so that Android can query any {@code SemanticsNode} at any time. This class
* represents a single node in the semantics tree, and it is a Java representation of the
* analogous concept within Flutter.
*
* To see how this {@code SemanticsNode}'s fields correspond to Flutter's semantics system, see
* semantics.dart: https://github.com/flutter/engine/blob/master/lib/ui/semantics.dart
*/
private static class SemanticsNode {
private static boolean nullableHasAncestor(
SemanticsNode target, Predicate This method only applies to this {@code SemanticsNode} and does not implicitly search its
* children.
*/
private boolean hasAction(@NonNull Action action) {
return (actions & action.value) != 0;
}
/**
* Returns true if the given {@code action} was supported by the immediately previous version of
* this {@code SemanticsNode}.
*/
private boolean hadAction(@NonNull Action action) {
return (previousActions & action.value) != 0;
}
private boolean hasFlag(@NonNull Flag flag) {
return (flags & flag.value) != 0;
}
private boolean hadFlag(@NonNull Flag flag) {
if (BuildConfig.DEBUG && !hadPreviousConfig) {
Log.e(TAG, "Attempted to check hadFlag but had no previous config.");
}
return (previousFlags & flag.value) != 0;
}
private boolean didScroll() {
return !Float.isNaN(scrollPosition)
&& !Float.isNaN(previousScrollPosition)
&& previousScrollPosition != scrollPosition;
}
private boolean didChangeLabel() {
if (label == null && previousLabel == null) {
return false;
}
return label == null || previousLabel == null || !label.equals(previousLabel);
}
private void log(@NonNull String indent, boolean recursive) {
if (BuildConfig.DEBUG) {
Log.i(
TAG,
indent
+ "SemanticsNode id="
+ id
+ " label="
+ label
+ " actions="
+ actions
+ " flags="
+ flags
+ "\n"
+ indent
+ " +-- textDirection="
+ textDirection
+ "\n"
+ indent
+ " +-- rect.ltrb=("
+ left
+ ", "
+ top
+ ", "
+ right
+ ", "
+ bottom
+ ")\n"
+ indent
+ " +-- transform="
+ Arrays.toString(transform)
+ "\n");
if (recursive) {
String childIndent = indent + " ";
for (SemanticsNode child : childrenInTraversalOrder) {
child.log(childIndent, recursive);
}
}
}
}
private void updateWith(
@NonNull ByteBuffer buffer,
@NonNull String[] strings,
@NonNull ByteBuffer[] stringAttributeArgs) {
hadPreviousConfig = true;
previousValue = value;
previousLabel = label;
previousFlags = flags;
previousActions = actions;
previousTextSelectionBase = textSelectionBase;
previousTextSelectionExtent = textSelectionExtent;
previousScrollPosition = scrollPosition;
previousScrollExtentMax = scrollExtentMax;
previousScrollExtentMin = scrollExtentMin;
flags = buffer.getInt();
actions = buffer.getInt();
maxValueLength = buffer.getInt();
currentValueLength = buffer.getInt();
textSelectionBase = buffer.getInt();
textSelectionExtent = buffer.getInt();
platformViewId = buffer.getInt();
scrollChildren = buffer.getInt();
scrollIndex = buffer.getInt();
scrollPosition = buffer.getFloat();
scrollExtentMax = buffer.getFloat();
scrollExtentMin = buffer.getFloat();
int stringIndex = buffer.getInt();
label = stringIndex == -1 ? null : strings[stringIndex];
labelAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs);
stringIndex = buffer.getInt();
value = stringIndex == -1 ? null : strings[stringIndex];
valueAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs);
stringIndex = buffer.getInt();
increasedValue = stringIndex == -1 ? null : strings[stringIndex];
increasedValueAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs);
stringIndex = buffer.getInt();
decreasedValue = stringIndex == -1 ? null : strings[stringIndex];
decreasedValueAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs);
stringIndex = buffer.getInt();
hint = stringIndex == -1 ? null : strings[stringIndex];
hintAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs);
textDirection = TextDirection.fromInt(buffer.getInt());
left = buffer.getFloat();
top = buffer.getFloat();
right = buffer.getFloat();
bottom = buffer.getFloat();
if (transform == null) {
transform = new float[16];
}
for (int i = 0; i < 16; ++i) {
transform[i] = buffer.getFloat();
}
inverseTransformDirty = true;
globalGeometryDirty = true;
final int childCount = buffer.getInt();
childrenInTraversalOrder.clear();
childrenInHitTestOrder.clear();
for (int i = 0; i < childCount; ++i) {
SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt());
child.parent = this;
childrenInTraversalOrder.add(child);
}
for (int i = 0; i < childCount; ++i) {
SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt());
child.parent = this;
childrenInHitTestOrder.add(child);
}
final int actionCount = buffer.getInt();
if (actionCount == 0) {
customAccessibilityActions = null;
} else {
if (customAccessibilityActions == null)
customAccessibilityActions = new ArrayList<>(actionCount);
else customAccessibilityActions.clear();
for (int i = 0; i < actionCount; i++) {
CustomAccessibilityAction action =
accessibilityBridge.getOrCreateAccessibilityAction(buffer.getInt());
if (action.overrideId == Action.TAP.value) {
onTapOverride = action;
} else if (action.overrideId == Action.LONG_PRESS.value) {
onLongPressOverride = action;
} else {
// If we receive a different overrideId it means that we were passed
// a standard action to override that we don't yet support.
if (BuildConfig.DEBUG && action.overrideId != -1) {
Log.e(TAG, "Expected action.overrideId to be -1.");
}
customAccessibilityActions.add(action);
}
customAccessibilityActions.add(action);
}
}
}
private List This is used by embedded platform views to propagate accessibility events from their view
* hierarchy to the accessibility bridge.
*
* As the embedded view doesn't have to be the only View in the embedded hierarchy (it can have
* child views) and the event might have been originated from any view in this hierarchy, this
* method gets both a reference to the embedded platform view, and a reference to the view from
* its hierarchy that sent the event.
*
* @param embeddedView the embedded platform view for which the event is delegated
* @param eventOrigin the view in the embedded view's hierarchy that sent the event.
* @return True if the event was sent.
*/
public boolean externalViewRequestSendAccessibilityEvent(
View embeddedView, View eventOrigin, AccessibilityEvent event) {
if (!accessibilityViewEmbedder.requestSendAccessibilityEvent(
embeddedView, eventOrigin, event)) {
return false;
}
Integer virtualNodeId = accessibilityViewEmbedder.getRecordFlutterId(embeddedView, event);
if (virtualNodeId == null) {
return false;
}
switch (event.getEventType()) {
case AccessibilityEvent.TYPE_VIEW_HOVER_ENTER:
hoveredObject = null;
break;
case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED:
embeddedAccessibilityFocusedNodeId = virtualNodeId;
accessibilityFocusedSemanticsNode = null;
break;
case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED:
embeddedInputFocusedNodeId = null;
embeddedAccessibilityFocusedNodeId = null;
break;
case AccessibilityEvent.TYPE_VIEW_FOCUSED:
embeddedInputFocusedNodeId = virtualNodeId;
inputFocusedSemanticsNode = null;
break;
}
return true;
}
}
*
*/
// TODO(mattcarroll): under what conditions is this method expected to be invoked?
public void reset() {
flutterSemanticsTree.clear();
if (accessibilityFocusedSemanticsNode != null) {
sendAccessibilityEvent(
accessibilityFocusedSemanticsNode.id,
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
}
accessibilityFocusedSemanticsNode = null;
hoveredObject = null;
sendWindowContentChangeEvent(0);
}
/**
* Listener that can be set on a {@link AccessibilityBridge}, which is invoked any time
* accessibility is turned on/off, or touch exploration is turned on/off.
*/
public interface OnAccessibilityChangeListener {
void onAccessibilityChanged(boolean isAccessibilityEnabled, boolean isTouchExplorationEnabled);
}
// Must match SemanticsActions in semantics.dart
// https://github.com/flutter/engine/blob/master/lib/ui/semantics.dart
public enum Action {
TAP(1 << 0),
LONG_PRESS(1 << 1),
SCROLL_LEFT(1 << 2),
SCROLL_RIGHT(1 << 3),
SCROLL_UP(1 << 4),
SCROLL_DOWN(1 << 5),
INCREASE(1 << 6),
DECREASE(1 << 7),
SHOW_ON_SCREEN(1 << 8),
MOVE_CURSOR_FORWARD_BY_CHARACTER(1 << 9),
MOVE_CURSOR_BACKWARD_BY_CHARACTER(1 << 10),
SET_SELECTION(1 << 11),
COPY(1 << 12),
CUT(1 << 13),
PASTE(1 << 14),
DID_GAIN_ACCESSIBILITY_FOCUS(1 << 15),
DID_LOSE_ACCESSIBILITY_FOCUS(1 << 16),
CUSTOM_ACTION(1 << 17),
DISMISS(1 << 18),
MOVE_CURSOR_FORWARD_BY_WORD(1 << 19),
MOVE_CURSOR_BACKWARD_BY_WORD(1 << 20),
SET_TEXT(1 << 21);
public final int value;
Action(int value) {
this.value = value;
}
}
// Must match SemanticsFlag in semantics.dart
// https://github.com/flutter/engine/blob/master/lib/ui/semantics.dart
/* Package */ enum Flag {
HAS_CHECKED_STATE(1 << 0),
IS_CHECKED(1 << 1),
IS_SELECTED(1 << 2),
IS_BUTTON(1 << 3),
IS_TEXT_FIELD(1 << 4),
IS_FOCUSED(1 << 5),
HAS_ENABLED_STATE(1 << 6),
IS_ENABLED(1 << 7),
IS_IN_MUTUALLY_EXCLUSIVE_GROUP(1 << 8),
IS_HEADER(1 << 9),
IS_OBSCURED(1 << 10),
SCOPES_ROUTE(1 << 11),
NAMES_ROUTE(1 << 12),
IS_HIDDEN(1 << 13),
IS_IMAGE(1 << 14),
IS_LIVE_REGION(1 << 15),
HAS_TOGGLED_STATE(1 << 16),
IS_TOGGLED(1 << 17),
HAS_IMPLICIT_SCROLLING(1 << 18),
// The Dart API defines the following flag but it isn't used in Android.
// IS_MULTILINE(1 << 19);
IS_READ_ONLY(1 << 20),
IS_FOCUSABLE(1 << 21),
IS_LINK(1 << 22),
IS_SLIDER(1 << 23),
IS_KEYBOARD_KEY(1 << 24);
final int value;
Flag(int value) {
this.value = value;
}
}
// Must match the enum defined in window.dart.
private enum AccessibilityFeature {
ACCESSIBLE_NAVIGATION(1 << 0),
INVERT_COLORS(1 << 1), // NOT SUPPORTED
DISABLE_ANIMATIONS(1 << 2);
final int value;
AccessibilityFeature(int value) {
this.value = value;
}
}
private enum TextDirection {
UNKNOWN,
LTR,
RTL;
public static TextDirection fromInt(int value) {
switch (value) {
case 1:
return RTL;
case 2:
return LTR;
}
return UNKNOWN;
}
}
/**
* Accessibility action that is defined within a given Flutter application, as opposed to the
* standard accessibility actions that are available in the Flutter framework.
*
*