diff --git a/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java b/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java index f2d194275c3d4..41e128ecd63f7 100644 --- a/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java +++ b/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java @@ -20,6 +20,8 @@ import io.flutter.embedding.engine.FlutterEngine.EngineLifecycleListener; import io.flutter.embedding.engine.renderer.FlutterRenderer; import io.flutter.embedding.engine.renderer.OnFirstFrameRenderedListener; +import io.flutter.plugin.common.StandardMessageCodec; +import io.flutter.view.AccessibilityBridge; /** * Interface between Flutter embedding's Java code and Flutter engine's C/C++ code. @@ -323,6 +325,22 @@ private native void nativeDispatchPointerDataPacket(long nativePlatformViewId, ByteBuffer buffer, int position); + public void dispatchSemanticsAction(int id, @NonNull AccessibilityBridge.Action action) { + dispatchSemanticsAction(id, action, null); + } + + public void dispatchSemanticsAction(int id, @NonNull AccessibilityBridge.Action action, @Nullable Object args) { + ensureAttachedToNative(); + + ByteBuffer encodedArgs = null; + int position = 0; + if (args != null) { + encodedArgs = StandardMessageCodec.INSTANCE.encodeMessage(args); + position = encodedArgs.position(); + } + dispatchSemanticsAction(id, action.value, encodedArgs, position); + } + @UiThread public void dispatchSemanticsAction(int id, int action, ByteBuffer args, int argsPosition) { ensureAttachedToNative(); diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java index 4bfcd37973b58..7eed2619bebda 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java @@ -103,17 +103,17 @@ public interface AccessibilityMessageHandler { void announce(@NonNull String message); /** - * The user has tapped on the artifact with the given {@code nodeId}. + * The user has tapped on the widget with the given {@code nodeId}. */ void onTap(int nodeId); /** - * The user has long pressed on the artifact with the given {@code nodeId}. + * The user has long pressed on the widget with the given {@code nodeId}. */ void onLongPress(int nodeId); /** - * The user has opened a popup window, menu, dialog, etc. + * The user has opened a tooltip. */ void onTooltip(@NonNull String message); } diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index add434080c054..812170155ec75 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -6,28 +6,69 @@ import android.annotation.TargetApi; import android.app.Activity; +import android.content.ContentResolver; +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.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.annotation.NonNull; import android.support.annotation.RequiresApi; import android.util.Log; +import android.view.MotionEvent; import android.view.View; import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeInfo; import android.view.accessibility.AccessibilityNodeProvider; +import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.systemchannels.AccessibilityChannel; import io.flutter.util.Predicate; import java.nio.ByteBuffer; import java.util.*; -class AccessibilityBridge - extends AccessibilityNodeProvider { - private static final String TAG = "FlutterView"; +/** + * Bridge between Android's OS accessibility system and Flutter's accessibility system. + * + * An {@code AccessibilityBridge} requires: + * + * + * The {@code AccessibilityBridge} causes Android to treat Flutter {@code SemanticsNode}s as if + * they were accessible Android {@link View}s. Accessibility requests may be sent from + * a Flutter widget to the Android OS, as if it were an Android {@link View}, and + * accessibility events may be consumed by a Flutter widget, as if it were an Android + * {@link View}. {@code AccessibilityBridge} refers to Flutter's accessible widgets as + * "virtual views" and identifies them with "virtual view IDs". + * + * Most communication between the Android OS accessibility system and Flutter's accessibility + * system is achieved via the {@link AccessibilityChannel} system channel. However, some + * information is exchanged directly between the Android embedding and Flutter framework + * via {@link FlutterJNI}. + * TODO(mattcarroll): consider moving FlutterJNI calls over to AccessibilityChannel + */ +public class AccessibilityBridge extends AccessibilityNodeProvider { + private static final String TAG = "AccessibilityBridge"; // Constants from higher API levels. // TODO(goderbauer): Get these from Android Support Library when @@ -38,35 +79,160 @@ class AccessibilityBridge private static final float SCROLL_POSITION_CAP_FOR_INFINITY = 70000.0f; private static final int ROOT_NODE_ID = 0; - private final FlutterView owner; + /// Value is derived from ACTION_TYPE_MASK in AccessibilityNodeInfo.java + private static int FIRST_RESOURCE_ID = 267386881; + + // Real Android View, which internally holds a Flutter UI. + @NonNull + private final View rootAccessibilityView; + + // Direct interface between Flutter's Android embedding and the Flutter framework. + @NonNull + private final FlutterJNI flutterJNI; + + // The accessibility communication API between Flutter's Android embedding and + // the Flutter framework. + @NonNull private final AccessibilityChannel accessibilityChannel; + + // Android's {@link AccessibilityManager}, which we can query to see if accessibility is + // turned on, as well as listen for changes to accessibility's activation. + @NonNull + private final AccessibilityManager accessibilityManager; + + // Android's {@link ContentResolver}, which is used to observe the global TRANSITION_ANIMATION_SCALE, + // which determines whether Flutter's animations should be enabled or disabled for accessibility + // purposes. + @NonNull + private final ContentResolver contentResolver; + + // The top-level Android View within the containing Window. + // TODO(mattcarroll): Move communication with the decorView out to FlutterView, or even FlutterActivity. + // The reason this is here is because when the device is in reverse-landscape + // orientation, Android has a bug where it assumes the OS nav bar is on the + // right side of the screen, not the left. As a result, accessibility borders + // are drawn too far to the left. The AccessibilityBridge directly adjusts + // for this Android bug. We still need to adjust, but this is the wrong place + // to access a decorView. What if the FlutterView is only part of the UI + // hierarchy, like a list item? We shouldn't touch the decor view. + // https://github.com/flutter/flutter/issues/19967 + @NonNull private final View decorView; - private Map objects; - private Map customAccessibilityActions; - private boolean accessibilityEnabled = false; - private SemanticsObject a11yFocusedObject; - private SemanticsObject inputFocusedObject; - private SemanticsObject hoveredObject; + + // The entire Flutter semantics tree of the running Flutter app, stored as a Map + // from each SemanticsNode's ID to a Java representation of a Flutter SemanticsNode. + // + // Flutter's semantics tree is cached here because Android might ask for information about + // a given SemanticsNode at any moment in time. Caching the tree allows for immediate + // response to Android's request. + // + // The structure of flutterSemanticsTree may be 1 or 2 frames behind the Flutter app + // due to the time required to communicate tree changes from Flutter to Android. + // + // See the Flutter docs on SemanticsNode: + // https://docs.flutter.io/flutter/semantics/SemanticsNode-class.html + @NonNull + private final Map flutterSemanticsTree = new HashMap<>(); + + // The set of all custom Flutter accessibility actions that are present in the running + // Flutter app, stored as a Map from each action's ID to the definition of the custom accessibility + // action. + // + // 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://docs.flutter.io/flutter/widgets/Semantics-class.html. + // {@code customAccessibilityActions} are an Android-side cache of all custom accessibility + // types declared within the running Flutter app. + // + // Custom accessibility actions are comprised of only a few fields, and therefore it is likely + // that a given app may define the same custom accessibility action many times. Identical + // custom accessibility actions are de-duped such that {@code customAccessibilityActions} only + // caches unique custom accessibility actions. + // + // 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://docs.flutter.io/flutter/widgets/Semantics-class.html + @NonNull + private final Map customAccessibilityActions = new HashMap<>(); + + // The {@code SemanticsNode} within Flutter that currently has the focus of Android's + // accessibility system. + @Nullable + private SemanticsNode accessibilityFocusedSemanticsNode; + + // The accessibility features that should currently be active within Flutter, represented as + // a bitmask whose values comes from {@link AccessibilityFeature}. + private int accessibilityFeatureFlags = 0; + + // The {@code SemanticsNode} within Flutter that currently has the focus of Android's input + // system. + // + // Input focus is independent of accessibility focus. It is possible that accessibility focus + // and input focus target the same {@code SemanticsNode}, but it is also possible that one + // {@code SemanticsNode} has input focus while a different {@code SemanticsNode} has + // accessibility focus. For example, a user may use a D-Pad to navigate to a text field, giving + // it accessibility focus, and then enable input on that text field, giving it input focus. Then + // the user moves the accessibility focus to a nearby label to get info about the label, while + // maintaining input focus on the original text field. + @Nullable + private SemanticsNode inputFocusedSemanticsNode; + + // The widget within Flutter that currently sits beneath a cursor, e.g, + // beneath a stylus or mouse cursor. + @Nullable + private SemanticsNode hoveredObject; + + // A Java/Android cached representation of the Flutter app's navigation stack. The Flutter + // navigation stack is tracked so that accessibility announcements can be made during Flutter's + // navigation changes. + // TODO(mattcarroll): take this cache into account for new routing solution so accessibility does + // not get left behind. + @NonNull + private final List flutterNavigationStack = new ArrayList<>(); + + // TODO(mattcarroll): why do we need previouseRouteId if we have flutterNavigationStack private int previousRouteId = ROOT_NODE_ID; - private List previousRoutes; + + // TODO(mattcarroll): is this for the decor view adjustment? + @NonNull private Integer lastLeftFrameInset = 0; + @Nullable + private OnAccessibilityChangeListener onAccessibilityChangeListener; + + // Handler for all messages received from Flutter via the {@code accessibilityChannel} private final AccessibilityChannel.AccessibilityMessageHandler accessibilityMessageHandler = new AccessibilityChannel.AccessibilityMessageHandler() { + /** + * The Dart application would like the given {@code message} to be announced. + */ @Override public void announce(@NonNull String message) { - owner.announceForAccessibility(message); + rootAccessibilityView.announceForAccessibility(message); } + /** + * The user has tapped on the widget with the given {@code nodeId}. + */ @Override public void onTap(int nodeId) { sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_CLICKED); } + /** + * The user has long pressed on the widget with the given {@code nodeId}. + */ @Override public void onLongPress(int nodeId) { sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_LONG_CLICKED); } + /** + * The user has opened a tooltip. + */ @Override public void onTooltip(@NonNull String message) { AccessibilityEvent e = obtainAccessibilityEvent(ROOT_NODE_ID, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); @@ -75,164 +241,262 @@ public void onTooltip(@NonNull String message) { } }; - 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), - /** These actions are only supported on Android 4.3 and above. */ - SET_SELECTION(1 << 11), - COPY(1 << 12), - CUT(1 << 13), - PASTE(1 << 14), - /** End 4.3 only supported actions. */ - 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); + // Listener that is notified when accessibility is turned on/off. + private final AccessibilityManager.AccessibilityStateChangeListener accessibilityStateChangeListener = new AccessibilityManager.AccessibilityStateChangeListener() { + @Override + public void onAccessibilityStateChanged(boolean accessibilityEnabled) { + if (accessibilityEnabled) { + accessibilityChannel.setAccessibilityMessageHandler(accessibilityMessageHandler); + flutterJNI.setSemanticsEnabled(true); + } else { + accessibilityChannel.setAccessibilityMessageHandler(null); + flutterJNI.setSemanticsEnabled(false); + } - Action(int value) { - this.value = value; + if (onAccessibilityChangeListener != null) { + onAccessibilityChangeListener.onAccessibilityChanged( + accessibilityEnabled, + accessibilityManager.isTouchExplorationEnabled() + ); + } } + }; - final int value; - } + // Listener that is notified when accessibility touch exploration is turned on/off. + // This is guarded at instantiation time. + @TargetApi(19) + @RequiresApi(19) + private final AccessibilityManager.TouchExplorationStateChangeListener touchExplorationStateChangeListener = new AccessibilityManager.TouchExplorationStateChangeListener() { + @Override + public void onTouchExplorationStateChanged(boolean isTouchExplorationEnabled) { + if (isTouchExplorationEnabled) { + accessibilityFeatureFlags |= AccessibilityFeature.ACCESSIBLE_NAVIGATION.value; + } else { + onTouchExplorationExit(); + accessibilityFeatureFlags &= ~AccessibilityFeature.ACCESSIBLE_NAVIGATION.value; + } + sendLatestAccessibilityFlagsToFlutter(); - 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); + if (onAccessibilityChangeListener != null) { + onAccessibilityChangeListener.onAccessibilityChanged( + accessibilityManager.isEnabled(), + isTouchExplorationEnabled + ); + } + } + }; - Flag(int value) { - this.value = value; + // Listener that is notified when the global TRANSITION_ANIMATION_SCALE. When this scale goes + // to zero, we instruct Flutter to disable animations. + private final ContentObserver animationScaleObserver = new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + this.onChange(selfChange, null); } - final int value; - } + @Override + public void onChange(boolean selfChange, Uri uri) { + // Retrieve the current value of TRANSITION_ANIMATION_SCALE from the OS. + String value = Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1 ? null + : Settings.Global.getString( + contentResolver, + Settings.Global.TRANSITION_ANIMATION_SCALE + ); + + boolean shouldAnimationsBeDisabled = value != null && value.equals("0"); + if (shouldAnimationsBeDisabled) { + accessibilityFeatureFlags |= AccessibilityFeature.DISABLE_ANIMATIONS.value; + } else { + accessibilityFeatureFlags &= ~AccessibilityFeature.DISABLE_ANIMATIONS.value; + } + sendLatestAccessibilityFlagsToFlutter(); + } + }; - AccessibilityBridge(@NonNull FlutterView owner, @NonNull AccessibilityChannel accessibilityChannel) { - this.owner = owner; + AccessibilityBridge( + @NonNull View rootAccessibilityView, + @NonNull FlutterJNI flutterJNI, + @NonNull AccessibilityChannel accessibilityChannel, + @NonNull AccessibilityManager accessibilityManager, + @NonNull ContentResolver contentResolver + ) { + this.rootAccessibilityView = rootAccessibilityView; + this.flutterJNI = flutterJNI; this.accessibilityChannel = accessibilityChannel; - decorView = ((Activity) owner.getContext()).getWindow().getDecorView(); - objects = new HashMap<>(); - customAccessibilityActions = new HashMap<>(); - previousRoutes = new ArrayList<>(); - } + this.accessibilityManager = accessibilityManager; + this.contentResolver = contentResolver; - void setAccessibilityEnabled(boolean accessibilityEnabled) { - this.accessibilityEnabled = accessibilityEnabled; - if (accessibilityEnabled) { - this.accessibilityChannel.setAccessibilityMessageHandler(accessibilityMessageHandler); - } else { - this.accessibilityChannel.setAccessibilityMessageHandler(null); + decorView = ((Activity) rootAccessibilityView.getContext()).getWindow().getDecorView(); + + // Tell Flutter whether accessibility is initially active or not. Then register a listener + // to be notified of changes in the future. + accessibilityStateChangeListener.onAccessibilityStateChanged(accessibilityManager.isEnabled()); + this.accessibilityManager.addAccessibilityStateChangeListener(accessibilityStateChangeListener); + + // Tell Flutter whether touch exploration is initially active or not. Then register a listener + // to be notified of changes in the future. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + touchExplorationStateChangeListener.onTouchExplorationStateChanged(accessibilityManager.isTouchExplorationEnabled()); + this.accessibilityManager.addTouchExplorationStateChangeListener(touchExplorationStateChangeListener); + } + + // Tell Flutter whether animations should initially be enabled or disabled. Then register a + // listener to be notified of changes in the future. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + animationScaleObserver.onChange(false); + Uri transitionUri = Settings.Global.getUriFor(Settings.Global.TRANSITION_ANIMATION_SCALE); + this.contentResolver.registerContentObserver(transitionUri, false, animationScaleObserver); } } - private boolean shouldSetCollectionInfo(final SemanticsObject object) { + /** + * Disconnects any listeners and/or delegates that were initialized in {@code AccessibilityBridge}'s + * constructor, or added after. + * + * 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() { + setOnAccessibilityChangeListener(null); + accessibilityManager.removeAccessibilityStateChangeListener(accessibilityStateChangeListener); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + accessibilityManager.removeTouchExplorationStateChangeListener(touchExplorationStateChangeListener); + } + contentResolver.unregisterContentObserver(animationScaleObserver); + } + + /** + * 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 via {@link FlutterJNI}. + */ + private void sendLatestAccessibilityFlagsToFlutter() { + flutterJNI.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 object - otherwise, if there + // 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 object.scrollChildren > 0 - && (hasSemanticsObjectAncestor(a11yFocusedObject, o -> o == object) - || !hasSemanticsObjectAncestor(a11yFocusedObject, o -> o.hasFlag(Flag.HAS_IMPLICIT_SCROLLING))); + return semanticsNode.scrollChildren > 0 + && (SemanticsNode.nullableHasAncestor(accessibilityFocusedSemanticsNode, o -> o == semanticsNode) + || !SemanticsNode.nullableHasAncestor(accessibilityFocusedSemanticsNode, o -> o.hasFlag(Flag.HAS_IMPLICIT_SCROLLING))); } + /** + * 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") public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { if (virtualViewId == View.NO_ID) { - AccessibilityNodeInfo result = AccessibilityNodeInfo.obtain(owner); - owner.onInitializeAccessibilityNodeInfo(result); - if (objects.containsKey(ROOT_NODE_ID)) { - result.addChild(owner, ROOT_NODE_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; } - SemanticsObject object = objects.get(virtualViewId); - if (object == null) { + SemanticsNode semanticsNode = flutterSemanticsTree.get(virtualViewId); + if (semanticsNode == null) { return null; } - AccessibilityNodeInfo result = AccessibilityNodeInfo.obtain(owner, virtualViewId); + AccessibilityNodeInfo result = AccessibilityNodeInfo.obtain(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(owner.getContext().getPackageName()); + result.setPackageName(rootAccessibilityView.getContext().getPackageName()); result.setClassName("android.view.View"); - result.setSource(owner, virtualViewId); - result.setFocusable(object.isFocusable()); - if (inputFocusedObject != null) { - result.setFocused(inputFocusedObject.id == virtualViewId); + result.setSource(rootAccessibilityView, virtualViewId); + result.setFocusable(semanticsNode.isFocusable()); + if (inputFocusedSemanticsNode != null) { + result.setFocused(inputFocusedSemanticsNode.id == virtualViewId); } - if (a11yFocusedObject != null) { - result.setAccessibilityFocused(a11yFocusedObject.id == virtualViewId); + if (accessibilityFocusedSemanticsNode != null) { + result.setAccessibilityFocused(accessibilityFocusedSemanticsNode.id == virtualViewId); } - if (object.hasFlag(Flag.IS_TEXT_FIELD)) { - result.setPassword(object.hasFlag(Flag.IS_OBSCURED)); + if (semanticsNode.hasFlag(Flag.IS_TEXT_FIELD)) { + result.setPassword(semanticsNode.hasFlag(Flag.IS_OBSCURED)); result.setClassName("android.widget.EditText"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { result.setEditable(true); - if (object.textSelectionBase != -1 && object.textSelectionExtent != -1) { - result.setTextSelection(object.textSelectionBase, object.textSelectionExtent); + 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 && a11yFocusedObject != null && a11yFocusedObject.id == virtualViewId) { + 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 (object.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) { + if (semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) { result.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER; } - if (object.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) { + if (semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) { result.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER; } - if (object.hasAction(Action.MOVE_CURSOR_FORWARD_BY_WORD)) { + if (semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_WORD)) { result.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD; } - if (object.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_WORD)) { + if (semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_WORD)) { result.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD; } @@ -242,44 +506,44 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { // 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 (object.hasAction(Action.SET_SELECTION)) { + if (semanticsNode.hasAction(Action.SET_SELECTION)) { result.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION); } - if (object.hasAction(Action.COPY)) { + if (semanticsNode.hasAction(Action.COPY)) { result.addAction(AccessibilityNodeInfo.ACTION_COPY); } - if (object.hasAction(Action.CUT)) { + if (semanticsNode.hasAction(Action.CUT)) { result.addAction(AccessibilityNodeInfo.ACTION_CUT); } - if (object.hasAction(Action.PASTE)) { + if (semanticsNode.hasAction(Action.PASTE)) { result.addAction(AccessibilityNodeInfo.ACTION_PASTE); } } - if (object.hasFlag(Flag.IS_BUTTON)) { + if (semanticsNode.hasFlag(Flag.IS_BUTTON)) { result.setClassName("android.widget.Button"); } - if (object.hasFlag(Flag.IS_IMAGE)) { + 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 && object.hasAction(Action.DISMISS)) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2 && semanticsNode.hasAction(Action.DISMISS)) { result.setDismissable(true); result.addAction(AccessibilityNodeInfo.ACTION_DISMISS); } - if (object.parent != null) { - assert object.id > ROOT_NODE_ID; - result.setParent(owner, object.parent.id); + if (semanticsNode.parent != null) { + assert semanticsNode.id > ROOT_NODE_ID; + result.setParent(rootAccessibilityView, semanticsNode.parent.id); } else { - assert object.id == ROOT_NODE_ID; - result.setParent(owner); + assert semanticsNode.id == ROOT_NODE_ID; + result.setParent(rootAccessibilityView); } - Rect bounds = object.getGlobalRect(); - if (object.parent != null) { - Rect parentBounds = object.parent.getGlobalRect(); + 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); @@ -289,30 +553,35 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { result.setBoundsInScreen(bounds); result.setVisibleToUser(true); result.setEnabled( - !object.hasFlag(Flag.HAS_ENABLED_STATE) || object.hasFlag(Flag.IS_ENABLED)); + !semanticsNode.hasFlag(Flag.HAS_ENABLED_STATE) || semanticsNode.hasFlag(Flag.IS_ENABLED) + ); - if (object.hasAction(Action.TAP)) { - if (Build.VERSION.SDK_INT >= 21 && object.onTapOverride != null) { + if (semanticsNode.hasAction(Action.TAP)) { + if (Build.VERSION.SDK_INT >= 21 && semanticsNode.onTapOverride != null) { result.addAction(new AccessibilityNodeInfo.AccessibilityAction( - AccessibilityNodeInfo.ACTION_CLICK, object.onTapOverride.hint)); + AccessibilityNodeInfo.ACTION_CLICK, + semanticsNode.onTapOverride.hint + )); result.setClickable(true); } else { result.addAction(AccessibilityNodeInfo.ACTION_CLICK); result.setClickable(true); } } - if (object.hasAction(Action.LONG_PRESS)) { - if (Build.VERSION.SDK_INT >= 21 && object.onLongPressOverride != null) { - result.addAction(new AccessibilityNodeInfo.AccessibilityAction(AccessibilityNodeInfo.ACTION_LONG_CLICK, - object.onLongPressOverride.hint)); + 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 (object.hasAction(Action.SCROLL_LEFT) || object.hasAction(Action.SCROLL_UP) - || object.hasAction(Action.SCROLL_RIGHT) || object.hasAction(Action.SCROLL_DOWN)) { + 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 @@ -327,22 +596,24 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { // // 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 (object.hasFlag(Flag.HAS_IMPLICIT_SCROLLING)) { - if (object.hasAction(Action.SCROLL_LEFT) || object.hasAction(Action.SCROLL_RIGHT)) { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT && shouldSetCollectionInfo(object)) { + 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 - object.scrollChildren, // columns - false)); // hierarchical + semanticsNode.scrollChildren, // columns + false // hierarchical + )); } else { result.setClassName("android.widget.HorizontalScrollView"); } } else { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2 && shouldSetCollectionInfo(object)) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2 && shouldSetCollectionInfo(semanticsNode)) { result.setCollectionInfo(AccessibilityNodeInfo.CollectionInfo.obtain( - object.scrollChildren, // rows + semanticsNode.scrollChildren, // rows 0, // columns - false)); // hierarchical + false // hierarchical + )); } else { result.setClassName("android.widget.ScrollView"); } @@ -351,53 +622,54 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { // TODO(ianh): Once we're on SDK v23+, call addAction to // expose AccessibilityAction.ACTION_SCROLL_LEFT, _RIGHT, // _UP, and _DOWN when appropriate. - if (object.hasAction(Action.SCROLL_LEFT) || object.hasAction(Action.SCROLL_UP)) { + if (semanticsNode.hasAction(Action.SCROLL_LEFT) || semanticsNode.hasAction(Action.SCROLL_UP)) { result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); } - if (object.hasAction(Action.SCROLL_RIGHT) || object.hasAction(Action.SCROLL_DOWN)) { + if (semanticsNode.hasAction(Action.SCROLL_RIGHT) || semanticsNode.hasAction(Action.SCROLL_DOWN)) { result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); } } - if (object.hasAction(Action.INCREASE) || object.hasAction(Action.DECREASE)) { + 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 (object.hasAction(Action.INCREASE)) { + if (semanticsNode.hasAction(Action.INCREASE)) { result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); } - if (object.hasAction(Action.DECREASE)) { + if (semanticsNode.hasAction(Action.DECREASE)) { result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); } } - if (object.hasFlag(Flag.IS_LIVE_REGION) && Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { + if (semanticsNode.hasFlag(Flag.IS_LIVE_REGION) && Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { result.setLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); } - boolean hasCheckedState = object.hasFlag(Flag.HAS_CHECKED_STATE); - boolean hasToggledState = object.hasFlag(Flag.HAS_TOGGLED_STATE); + boolean hasCheckedState = semanticsNode.hasFlag(Flag.HAS_CHECKED_STATE); + boolean hasToggledState = semanticsNode.hasFlag(Flag.HAS_TOGGLED_STATE); assert !(hasCheckedState && hasToggledState); result.setCheckable(hasCheckedState || hasToggledState); if (hasCheckedState) { - result.setChecked(object.hasFlag(Flag.IS_CHECKED)); - result.setContentDescription(object.getValueLabelHint()); - if (object.hasFlag(Flag.IS_IN_MUTUALLY_EXCLUSIVE_GROUP)) + result.setChecked(semanticsNode.hasFlag(Flag.IS_CHECKED)); + result.setContentDescription(semanticsNode.getValueLabelHint()); + if (semanticsNode.hasFlag(Flag.IS_IN_MUTUALLY_EXCLUSIVE_GROUP)) { result.setClassName("android.widget.RadioButton"); - else + } else { result.setClassName("android.widget.CheckBox"); + } } else if (hasToggledState) { - result.setChecked(object.hasFlag(Flag.IS_TOGGLED)); + result.setChecked(semanticsNode.hasFlag(Flag.IS_TOGGLED)); result.setClassName("android.widget.Switch"); - result.setContentDescription(object.getValueLabelHint()); + result.setContentDescription(semanticsNode.getValueLabelHint()); } else { // Setting the text directly instead of the content description // will replace the "checked" or "not-checked" label. - result.setText(object.getValueLabelHint()); + result.setText(semanticsNode.getValueLabelHint()); } - result.setSelected(object.hasFlag(Flag.IS_SELECTED)); + result.setSelected(semanticsNode.hasFlag(Flag.IS_SELECTED)); // Accessibility Focus - if (a11yFocusedObject != null && a11yFocusedObject.id == virtualViewId) { + if (accessibilityFocusedSemanticsNode != null && accessibilityFocusedSemanticsNode.id == virtualViewId) { result.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); } else { result.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); @@ -405,18 +677,20 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { // Actions on the local context menu if (Build.VERSION.SDK_INT >= 21) { - if (object.customAccessibilityActions != null) { - for (CustomAccessibilityAction action : object.customAccessibilityActions) { + if (semanticsNode.customAccessibilityActions != null) { + for (CustomAccessibilityAction action : semanticsNode.customAccessibilityActions) { result.addAction(new AccessibilityNodeInfo.AccessibilityAction( - action.resourceId, action.label)); + action.resourceId, + action.label + )); } } } - if (object.childrenInTraversalOrder != null) { - for (SemanticsObject child : object.childrenInTraversalOrder) { + if (semanticsNode.childrenInTraversalOrder != null) { + for (SemanticsNode child : semanticsNode.childrenInTraversalOrder) { if (!child.hasFlag(Flag.IS_HIDDEN)) { - result.addChild(owner, child.id); + result.addChild(rootAccessibilityView, child.id); } } } @@ -424,54 +698,68 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { return result; } + /** + * 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 + * via {@link FlutterJNI}. + */ @Override - public boolean performAction(int virtualViewId, int action, Bundle arguments) { - SemanticsObject object = objects.get(virtualViewId); - if (object == null) { + public boolean performAction(int virtualViewId, int accessibilityAction, @Nullable Bundle arguments) { + SemanticsNode semanticsNode = flutterSemanticsTree.get(virtualViewId); + if (semanticsNode == null) { return false; } - switch (action) { + 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. - owner.dispatchSemanticsAction(virtualViewId, Action.TAP); + flutterJNI.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. - owner.dispatchSemanticsAction(virtualViewId, Action.LONG_PRESS); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.LONG_PRESS); return true; } case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: { - if (object.hasAction(Action.SCROLL_UP)) { - owner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_UP); - } else if (object.hasAction(Action.SCROLL_LEFT)) { + if (semanticsNode.hasAction(Action.SCROLL_UP)) { + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_UP); + } else if (semanticsNode.hasAction(Action.SCROLL_LEFT)) { // TODO(ianh): bidi support using textDirection - owner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_LEFT); - } else if (object.hasAction(Action.INCREASE)) { - object.value = object.increasedValue; + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_LEFT); + } else if (semanticsNode.hasAction(Action.INCREASE)) { + semanticsNode.value = semanticsNode.increasedValue; // Event causes Android to read out the updated value. sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); - owner.dispatchSemanticsAction(virtualViewId, Action.INCREASE); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.INCREASE); } else { return false; } return true; } case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: { - if (object.hasAction(Action.SCROLL_DOWN)) { - owner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_DOWN); - } else if (object.hasAction(Action.SCROLL_RIGHT)) { + if (semanticsNode.hasAction(Action.SCROLL_DOWN)) { + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_DOWN); + } else if (semanticsNode.hasAction(Action.SCROLL_RIGHT)) { // TODO(ianh): bidi support using textDirection - owner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_RIGHT); - } else if (object.hasAction(Action.DECREASE)) { - object.value = object.decreasedValue; + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_RIGHT); + } else if (semanticsNode.hasAction(Action.DECREASE)) { + semanticsNode.value = semanticsNode.decreasedValue; // Event causes Android to read out the updated value. sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); - owner.dispatchSemanticsAction(virtualViewId, Action.DECREASE); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.DECREASE); } else { return false; } @@ -484,7 +772,7 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { return false; } - return performCursorMoveAction(object, virtualViewId, arguments, 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 @@ -493,29 +781,39 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { return false; } - return performCursorMoveAction(object, virtualViewId, arguments, true); + return performCursorMoveAction(semanticsNode, virtualViewId, arguments, true); } case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS: { - owner.dispatchSemanticsAction(virtualViewId, Action.DID_LOSE_ACCESSIBILITY_FOCUS); + flutterJNI.dispatchSemanticsAction( + virtualViewId, + Action.DID_LOSE_ACCESSIBILITY_FOCUS + ); sendAccessibilityEvent( - virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); - a11yFocusedObject = null; + virtualViewId, + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED + ); + accessibilityFocusedSemanticsNode = null; return true; } case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: { - owner.dispatchSemanticsAction(virtualViewId, Action.DID_GAIN_ACCESSIBILITY_FOCUS); + flutterJNI.dispatchSemanticsAction( + virtualViewId, + Action.DID_GAIN_ACCESSIBILITY_FOCUS + ); sendAccessibilityEvent( - virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); + virtualViewId, + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED + ); - if (a11yFocusedObject == null) { + 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.) - owner.invalidate(); + rootAccessibilityView.invalidate(); } - a11yFocusedObject = object; + accessibilityFocusedSemanticsNode = semanticsNode; - if (object.hasAction(Action.INCREASE) || object.hasAction(Action.DECREASE)) { + if (semanticsNode.hasAction(Action.INCREASE) || semanticsNode.hasAction(Action.DECREASE)) { // SeekBars only announce themselves after this event. sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); } @@ -523,7 +821,7 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { return true; } case ACTION_SHOW_ON_SCREEN: { - owner.dispatchSemanticsAction(virtualViewId, Action.SHOW_ON_SCREEN); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SHOW_ON_SCREEN); return true; } case AccessibilityNodeInfo.ACTION_SET_SELECTION: { @@ -535,86 +833,110 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { } final Map selection = new HashMap<>(); final boolean hasSelection = arguments != null - && arguments.containsKey( - AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT) - && arguments.containsKey( - AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT); + && arguments.containsKey(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT) + && arguments.containsKey(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT); if (hasSelection) { - selection.put("base", - arguments.getInt( - AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT)); - selection.put("extent", - arguments.getInt( - AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT)); + selection.put( + "base", + arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT) + ); + selection.put( + "extent", + arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT) + ); } else { // Clear the selection - selection.put("base", object.textSelectionExtent); - selection.put("extent", object.textSelectionExtent); + selection.put("base", semanticsNode.textSelectionExtent); + selection.put("extent", semanticsNode.textSelectionExtent); } - owner.dispatchSemanticsAction(virtualViewId, Action.SET_SELECTION, selection); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SET_SELECTION, selection); return true; } case AccessibilityNodeInfo.ACTION_COPY: { - owner.dispatchSemanticsAction(virtualViewId, Action.COPY); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.COPY); return true; } case AccessibilityNodeInfo.ACTION_CUT: { - owner.dispatchSemanticsAction(virtualViewId, Action.CUT); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.CUT); return true; } case AccessibilityNodeInfo.ACTION_PASTE: { - owner.dispatchSemanticsAction(virtualViewId, Action.PASTE); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.PASTE); return true; } case AccessibilityNodeInfo.ACTION_DISMISS: { - owner.dispatchSemanticsAction(virtualViewId, Action.DISMISS); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.DISMISS); return true; } default: - // might be a custom accessibility action. - final int flutterId = action - firstResourceId; - CustomAccessibilityAction contextAction = - customAccessibilityActions.get(flutterId); + // might be a custom accessibility accessibilityAction. + final int flutterId = accessibilityAction - FIRST_RESOURCE_ID; + CustomAccessibilityAction contextAction = customAccessibilityActions.get(flutterId); if (contextAction != null) { - owner.dispatchSemanticsAction( - virtualViewId, Action.CUSTOM_ACTION, contextAction.id); + flutterJNI.dispatchSemanticsAction( + virtualViewId, + Action.CUSTOM_ACTION, + contextAction.id + ); return true; } } return false; } - @RequiresApi(18) + /** + * Handles the responsibilities of {@link #performAction(int, int, Bundle)} for the specific + * scenario of cursor movement. + */ @TargetApi(18) - boolean performCursorMoveAction( - SemanticsObject object, int virtualViewId, Bundle arguments, boolean forward) { - final int granularity = - arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); + @RequiresApi(18) + private boolean performCursorMoveAction( + @NonNull SemanticsNode semanticsNode, + int virtualViewId, + @NonNull Bundle arguments, + boolean forward + ) { + final int granularity = arguments.getInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT + ); final boolean extendSelection = arguments.getBoolean( - AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN); + AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN + ); switch (granularity) { case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: { - if (forward && object.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) { - owner.dispatchSemanticsAction(virtualViewId, - Action.MOVE_CURSOR_FORWARD_BY_CHARACTER, extendSelection); + if (forward && semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) { + flutterJNI.dispatchSemanticsAction( + virtualViewId, + Action.MOVE_CURSOR_FORWARD_BY_CHARACTER, + extendSelection + ); return true; } - if (!forward && object.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) { - owner.dispatchSemanticsAction(virtualViewId, - Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER, extendSelection); + if (!forward && semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) { + flutterJNI.dispatchSemanticsAction( + virtualViewId, + Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER, + extendSelection + ); return true; } break; } case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD: - if (forward && object.hasAction(Action.MOVE_CURSOR_FORWARD_BY_WORD)) { - owner.dispatchSemanticsAction(virtualViewId, - Action.MOVE_CURSOR_FORWARD_BY_WORD, extendSelection); + if (forward && semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_WORD)) { + flutterJNI.dispatchSemanticsAction( + virtualViewId, + Action.MOVE_CURSOR_FORWARD_BY_WORD, + extendSelection + ); return true; } - if (!forward && object.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_WORD)) { - owner.dispatchSemanticsAction(virtualViewId, - Action.MOVE_CURSOR_BACKWARD_BY_WORD, extendSelection); + if (!forward && semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_WORD)) { + flutterJNI.dispatchSemanticsAction( + virtualViewId, + Action.MOVE_CURSOR_BACKWARD_BY_WORD, + extendSelection + ); return true; } break; @@ -624,76 +946,164 @@ boolean performCursorMoveAction( // TODO(ianh): implement findAccessibilityNodeInfosByText() + /** + * Finds the view in a hierarchy that currently has the given type of {@code focus}. + * + * 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 (inputFocusedObject != null) - return createAccessibilityNodeInfo(inputFocusedObject.id); + if (inputFocusedSemanticsNode != null) { + return createAccessibilityNodeInfo(inputFocusedSemanticsNode.id); + } } // Fall through to check FOCUS_ACCESSIBILITY case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY: { - if (a11yFocusedObject != null) - return createAccessibilityNodeInfo(a11yFocusedObject.id); + if (accessibilityFocusedSemanticsNode != null) { + return createAccessibilityNodeInfo(accessibilityFocusedSemanticsNode.id); + } } } return null; } - private SemanticsObject getRootObject() { - assert objects.containsKey(0); - return objects.get(0); + /** + * Returns the {@link SemanticsNode} at the root of Flutter's semantics tree. + */ + private SemanticsNode getRootSemanticsNode() { + assert flutterSemanticsTree.containsKey(0); + return flutterSemanticsTree.get(0); } - private SemanticsObject getOrCreateObject(int id) { - SemanticsObject object = objects.get(id); - if (object == null) { - object = new SemanticsObject(); - object.id = id; - objects.put(id, object); - } - return object; + /** + * 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; } - private CustomAccessibilityAction getOrCreateAction(int id) { + /** + * 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 + firstResourceId; + action.resourceId = id + FIRST_RESOURCE_ID; customAccessibilityActions.put(id, action); } return action; } - void handleTouchExplorationExit() { + /** + * 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 (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; } } - void handleTouchExploration(float x, float y) { - if (objects.isEmpty()) { + /** + * 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; } - SemanticsObject newObject = getRootObject().hitTest(new float[] {x, y, 0, 1}); - if (newObject != hoveredObject) { + SemanticsNode semanticsNodeUnderCursor = getRootSemanticsNode().hitTest(new float[] {x, y, 0, 1}); + if (semanticsNodeUnderCursor != hoveredObject) { // sending ENTER before EXIT is how Android wants it - if (newObject != null) { - sendAccessibilityEvent(newObject.id, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER); + if (semanticsNodeUnderCursor != null) { + sendAccessibilityEvent(semanticsNodeUnderCursor.id, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER); } if (hoveredObject != null) { sendAccessibilityEvent(hoveredObject.id, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); } - hoveredObject = newObject; + hoveredObject = semanticsNodeUnderCursor; } } - void updateCustomAccessibilityActions(ByteBuffer buffer, String[] strings) { + /** + * Updates the Android cache of Flutter's currently registered custom accessibility actions. + */ + // 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. + // TODO(mattcarroll): where is the encoding code for reference? + void updateCustomAccessibilityActions(@NonNull ByteBuffer buffer, @NonNull String[] strings) { while (buffer.hasRemaining()) { int id = buffer.getInt(); - CustomAccessibilityAction action = getOrCreateAction(id); + CustomAccessibilityAction action = getOrCreateAccessibilityAction(id); action.overrideId = buffer.getInt(); int stringIndex = buffer.getInt(); action.label = stringIndex == -1 ? null : strings[stringIndex]; @@ -702,26 +1112,32 @@ void updateCustomAccessibilityActions(ByteBuffer buffer, String[] strings) { } } - void updateSemantics(ByteBuffer buffer, String[] strings) { - ArrayList updated = new ArrayList<>(); + /** + * 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}. + */ + // TODO(mattcarroll): where is the encoding code for reference? + void updateSemantics(@NonNull ByteBuffer buffer, @NonNull String[] strings) { + ArrayList updated = new ArrayList<>(); while (buffer.hasRemaining()) { int id = buffer.getInt(); - SemanticsObject object = getOrCreateObject(id); - object.updateWith(buffer, strings); - if (object.hasFlag(Flag.IS_HIDDEN)) { + SemanticsNode semanticsNode = getOrCreateSemanticsNode(id); + semanticsNode.updateWith(buffer, strings); + if (semanticsNode.hasFlag(Flag.IS_HIDDEN)) { continue; } - if (object.hasFlag(Flag.IS_FOCUSED)) { - inputFocusedObject = object; + if (semanticsNode.hasFlag(Flag.IS_FOCUSED)) { + inputFocusedSemanticsNode = semanticsNode; } - if (object.hadPreviousConfig) { - updated.add(object); + if (semanticsNode.hadPreviousConfig) { + updated.add(semanticsNode); } } - Set visitedObjects = new HashSet<>(); - SemanticsObject rootObject = getRootObject(); - List newRoutes = new ArrayList<>(); + Set visitedObjects = new HashSet<>(); + SemanticsNode rootObject = getRootSemanticsNode(); + List newRoutes = new ArrayList<>(); if (rootObject != null) { final float[] identity = new float[16]; Matrix.setIdentityM(identity, 0); @@ -744,10 +1160,10 @@ void updateSemantics(ByteBuffer buffer, String[] strings) { // Dispatch a TYPE_WINDOW_STATE_CHANGED event if the most recent route id changed from the // previously cached route id. - SemanticsObject lastAdded = null; - for (SemanticsObject semanticsObject : newRoutes) { - if (!previousRoutes.contains(semanticsObject.id)) { - lastAdded = semanticsObject; + SemanticsNode lastAdded = null; + for (SemanticsNode semanticsNode : newRoutes) { + if (!flutterNavigationStack.contains(semanticsNode.id)) { + lastAdded = semanticsNode; } } if (lastAdded == null && newRoutes.size() > 0) { @@ -755,19 +1171,19 @@ void updateSemantics(ByteBuffer buffer, String[] strings) { } if (lastAdded != null && lastAdded.id != previousRouteId) { previousRouteId = lastAdded.id; - createWindowChangeEvent(lastAdded); + createAndSendWindowChangeEvent(lastAdded); } - previousRoutes.clear(); - for (SemanticsObject semanticsObject : newRoutes) { - previousRoutes.add(semanticsObject.id); + flutterNavigationStack.clear(); + for (SemanticsNode semanticsNode : newRoutes) { + flutterNavigationStack.add(semanticsNode.id); } - Iterator> it = objects.entrySet().iterator(); + Iterator> it = flutterSemanticsTree.entrySet().iterator(); while (it.hasNext()) { - Map.Entry entry = it.next(); - SemanticsObject object = entry.getValue(); + Map.Entry entry = it.next(); + SemanticsNode object = entry.getValue(); if (!visitedObjects.contains(object)) { - willRemoveSemanticsObject(object); + willRemoveSemanticsNode(object); it.remove(); } } @@ -776,7 +1192,7 @@ void updateSemantics(ByteBuffer buffer, String[] strings) { // see https://github.com/flutter/flutter/issues/14534 sendAccessibilityEvent(0, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); - for (SemanticsObject object : updated) { + for (SemanticsNode object : updated) { if (object.didScroll()) { AccessibilityEvent event = obtainAccessibilityEvent(object.id, AccessibilityEvent.TYPE_VIEW_SCROLLED); @@ -816,7 +1232,7 @@ void updateSemantics(ByteBuffer buffer, String[] strings) { event.setFromIndex(object.scrollIndex); int visibleChildren = 0; // handle hidden children at the beginning and end of the list. - for (SemanticsObject child : object.childrenInHitTestOrder) { + for (SemanticsNode child : object.childrenInHitTestOrder) { if (!child.hasFlag(Flag.IS_HIDDEN)) { visibleChildren += 1; } @@ -842,25 +1258,25 @@ void updateSemantics(ByteBuffer buffer, String[] strings) { sendAccessibilityEvent(object.id, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); } } else if (object.hasFlag(Flag.IS_TEXT_FIELD) && object.didChangeLabel() - && inputFocusedObject != null && inputFocusedObject.id == object.id) { + && inputFocusedSemanticsNode != null && inputFocusedSemanticsNode.id == object.id) { // Text fields should announce when their label changes while focused. We use a live // region tag to do so, and this event triggers that update. sendAccessibilityEvent(object.id, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); } - if (a11yFocusedObject != null && a11yFocusedObject.id == object.id + if (accessibilityFocusedSemanticsNode != null && accessibilityFocusedSemanticsNode.id == object.id && !object.hadFlag(Flag.IS_SELECTED) && object.hasFlag(Flag.IS_SELECTED)) { AccessibilityEvent event = obtainAccessibilityEvent(object.id, AccessibilityEvent.TYPE_VIEW_SELECTED); event.getText().add(object.label); sendAccessibilityEvent(event); } - if (inputFocusedObject != null && inputFocusedObject.id == object.id + if (inputFocusedSemanticsNode != null && inputFocusedSemanticsNode.id == object.id && object.hadFlag(Flag.IS_TEXT_FIELD) && object.hasFlag(Flag.IS_TEXT_FIELD) // If we have a TextField that has InputFocus, we should avoid announcing it if something // else we track has a11y focus. This needs to still work when, e.g., IME has a11y focus // or the "PASTE" popup is used though. // See more discussion at https://github.com/flutter/flutter/issues/23180 - && (a11yFocusedObject == null || (a11yFocusedObject.id == inputFocusedObject.id))) { + && (accessibilityFocusedSemanticsNode == null || (accessibilityFocusedSemanticsNode.id == inputFocusedSemanticsNode.id))) { String oldValue = object.previousValue != null ? object.previousValue : ""; String newValue = object.value != null ? object.value : ""; AccessibilityEvent event = createTextChangedEvent(object.id, oldValue, newValue); @@ -915,67 +1331,205 @@ private AccessibilityEvent createTextChangedEvent(int id, String oldValue, Strin return e; } - private AccessibilityEvent obtainAccessibilityEvent(int virtualViewId, int eventType) { - assert virtualViewId != ROOT_NODE_ID; - AccessibilityEvent event = AccessibilityEvent.obtain(eventType); - event.setPackageName(owner.getContext().getPackageName()); - event.setSource(owner, virtualViewId); - return event; - } - - private void sendAccessibilityEvent(int virtualViewId, int eventType) { - if (!accessibilityEnabled) { + /** + * Sends an accessibility event of the given {@code eventType} to Android's accessibility + * system with the given {@code viewId} represented as the source of the event. + * + * 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; } - if (virtualViewId == ROOT_NODE_ID) { - owner.sendAccessibilityEvent(eventType); + if (viewId == ROOT_NODE_ID) { + rootAccessibilityView.sendAccessibilityEvent(eventType); } else { - sendAccessibilityEvent(obtainAccessibilityEvent(virtualViewId, eventType)); + sendAccessibilityEvent(obtainAccessibilityEvent(viewId, eventType)); } } - private void sendAccessibilityEvent(AccessibilityEvent event) { - if (!accessibilityEnabled) { + /** + * 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; } - owner.getParent().requestSendAccessibilityEvent(owner, event); + // TODO(mattcarroll): why are we explicitly talking to the root view's parent? + rootAccessibilityView.getParent().requestSendAccessibilityEvent(rootAccessibilityView, event); } - private void createWindowChangeEvent(SemanticsObject route) { - AccessibilityEvent e = - obtainAccessibilityEvent(route.id, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + /** + * Factory method that creates a {@link AccessibilityEvent#TYPE_WINDOW_STATE_CHANGED} and sends + * the event to Android's accessibility system. + * + * The given {@code route} should be a {@link SemanticsNode} that represents a navigation route + * in the Flutter app. + */ + private void createAndSendWindowChangeEvent(@NonNull SemanticsNode route) { + AccessibilityEvent event = obtainAccessibilityEvent( + route.id, + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED + ); String routeName = route.getRouteName(); - e.getText().add(routeName); - sendAccessibilityEvent(e); + event.getText().add(routeName); + sendAccessibilityEvent(event); } - private void willRemoveSemanticsObject(SemanticsObject object) { - assert objects.containsKey(object.id); - assert objects.get(object.id) == object; - object.parent = null; - if (a11yFocusedObject == object) { - sendAccessibilityEvent(a11yFocusedObject.id, - AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); - a11yFocusedObject = null; - } - if (inputFocusedObject == object) { - inputFocusedObject = null; - } - if (hoveredObject == object) { + /** + * 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) { + assert virtualViewId != ROOT_NODE_ID; + AccessibilityEvent event = AccessibilityEvent.obtain(eventType); + event.setPackageName(rootAccessibilityView.getContext().getPackageName()); + event.setSource(rootAccessibilityView, virtualViewId); + return event; + } + + /** + * Hook called just before a {@link SemanticsNode} is removed from the Android cache of Flutter's + * semantics tree. + */ + private void willRemoveSemanticsNode(SemanticsNode semanticsNodeToBeRemoved) { + assert flutterSemanticsTree.containsKey(semanticsNodeToBeRemoved.id); + assert flutterSemanticsTree.get(semanticsNodeToBeRemoved.id) == semanticsNodeToBeRemoved; + // 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 (accessibilityFocusedSemanticsNode == semanticsNodeToBeRemoved) { + sendAccessibilityEvent( + accessibilityFocusedSemanticsNode.id, + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED + ); + accessibilityFocusedSemanticsNode = null; + } + if (inputFocusedSemanticsNode == semanticsNodeToBeRemoved) { + inputFocusedSemanticsNode = null; + } + if (hoveredObject == semanticsNodeToBeRemoved) { hoveredObject = null; } } - void reset() { - objects.clear(); - if (a11yFocusedObject != null) - sendAccessibilityEvent(a11yFocusedObject.id, - AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); - a11yFocusedObject = null; + /** + * Resets the {@code AccessibilityBridge}: + *
    + *
  • Clears {@link #flutterSemanticsTree}, the Android cache of Flutter's semantics tree
  • + *
  • Releases focus on any active {@link #accessibilityFocusedSemanticsNode}
  • + *
  • Clears any hovered {@code SemanticsNode}
  • + *
  • Sends a {@link AccessibilityEvent#TYPE_WINDOW_CONTENT_CHANGED} event
  • + *
+ */ + // 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; sendAccessibilityEvent(0, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); } + /** + * 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); + + 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 + private 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); + + 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, @@ -992,64 +1546,95 @@ public static TextDirection fromInt(int value) { } } - private class CustomAccessibilityAction { + /** + * Accessibility action that is defined within a given Flutter application, as opposed to the + * standard accessibility actions that are available in the Flutter framework. + * + * 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://docs.flutter.io/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://docs.flutter.io/flutter/widgets/Semantics-class.html + */ + private static class CustomAccessibilityAction { CustomAccessibilityAction() {} - /// Resource id is the id of the custom action plus a minimum value so that the identifier - /// does not collide with existing Android accessibility actions. - int resourceId = -1; - int id = -1; - int overrideId = -1; + // 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 label is the user presented value which is displayed in the local context menu. - String label; + // The Flutter ID of this custom accessibility action. See Flutter's Semantics widget for + // custom accessibility action definitions: https://docs.flutter.io/flutter/widgets/Semantics-class.html + private int id = -1; - /// The hint is the text used in overriden standard actions. - String hint; - - boolean isStandardAction() { - return overrideId != -1; - } - } - /// Value is derived from ACTION_TYPE_MASK in AccessibilityNodeInfo.java - static int firstResourceId = 267386881; + // 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; - static boolean hasSemanticsObjectAncestor(SemanticsObject target, Predicate tester) { - return target != null && target.getAncestor(tester) != null; + // The text used in overridden standard actions. + private String hint; } - private class SemanticsObject { - SemanticsObject() {} - - int id = -1; - - int flags; - int actions; - int textSelectionBase; - int textSelectionExtent; - int scrollChildren; - int scrollIndex; - float scrollPosition; - float scrollExtentMax; - float scrollExtentMin; - String label; - String value; - String increasedValue; - String decreasedValue; - String hint; - TextDirection textDirection; - - boolean hadPreviousConfig = false; - int previousFlags; - int previousActions; - int previousTextSelectionBase; - int previousTextSelectionExtent; - float previousScrollPosition; - float previousScrollExtentMax; - float previousScrollExtentMin; - String previousValue; - String previousLabel; + /** + * 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 tester) { + return target != null && target.getAncestor(tester) != null; + } + + final AccessibilityBridge accessibilityBridge; + + // Flutter ID of this {@code SemanticsNode}. + private int id = -1; + + private int flags; + private int actions; + private int textSelectionBase; + private int textSelectionExtent; + private int scrollChildren; + private int scrollIndex; + private float scrollPosition; + private float scrollExtentMax; + private float scrollExtentMin; + private String label; + private String value; + private String increasedValue; + private String decreasedValue; + private String hint; + + // See Flutter's {@code SemanticsNode#textDirection}. + private TextDirection textDirection; + + private boolean hadPreviousConfig = false; + private int previousFlags; + private int previousActions; + private int previousTextSelectionBase; + private int previousTextSelectionExtent; + private float previousScrollPosition; + private float previousScrollExtentMax; + private float previousScrollExtentMin; + private String previousValue; + private String previousLabel; private float left; private float top; @@ -1057,12 +1642,12 @@ private class SemanticsObject { private float bottom; private float[] transform; - SemanticsObject parent; - List childrenInTraversalOrder; - List childrenInHitTestOrder; - List customAccessibilityActions; - CustomAccessibilityAction onTapOverride; - CustomAccessibilityAction onLongPressOverride; + private SemanticsNode parent; + private List childrenInTraversalOrder; + private List childrenInHitTestOrder; + private List customAccessibilityActions; + private CustomAccessibilityAction onTapOverride; + private CustomAccessibilityAction onLongPressOverride; private boolean inverseTransformDirty = true; private float[] inverseTransform; @@ -1071,8 +1656,16 @@ private class SemanticsObject { private float[] globalTransform; private Rect globalRect; - SemanticsObject getAncestor(Predicate tester) { - SemanticsObject nextAncestor = parent; + SemanticsNode(@NonNull AccessibilityBridge accessibilityBridge) { + this.accessibilityBridge = accessibilityBridge; + } + + /** + * Returns the ancestor of this {@code SemanticsNode} for which {@link Predicate#test(Object)} + * returns true, or null if no such ancestor exists. + */ + private SemanticsNode getAncestor(Predicate tester) { + SemanticsNode nextAncestor = parent; while (nextAncestor != null) { if (tester.test(nextAncestor)) { return nextAncestor; @@ -1082,51 +1675,61 @@ SemanticsObject getAncestor(Predicate tester) { return null; } - boolean hasAction(Action action) { + /** + * Returns true if the given {@code action} is supported by this {@code SemanticsNode}. + * + * 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; } - boolean hadAction(Action action) { + /** + * 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; } - boolean hasFlag(Flag flag) { + private boolean hasFlag(@NonNull Flag flag) { return (flags & flag.value) != 0; } - boolean hadFlag(Flag flag) { + private boolean hadFlag(@NonNull Flag flag) { assert hadPreviousConfig; return (previousFlags & flag.value) != 0; } - boolean didScroll() { + private boolean didScroll() { return !Float.isNaN(scrollPosition) && !Float.isNaN(previousScrollPosition) && previousScrollPosition != scrollPosition; } - boolean didChangeLabel() { + private boolean didChangeLabel() { if (label == null && previousLabel == null) { return false; } return label == null || previousLabel == null || !label.equals(previousLabel); } - void log(String indent, boolean recursive) { + private void log(@NonNull String indent, boolean recursive) { Log.i(TAG, - indent + "SemanticsObject id=" + id + " label=" + label + " actions=" + actions + 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 (childrenInTraversalOrder != null && recursive) { String childIndent = indent + " "; - for (SemanticsObject child : childrenInTraversalOrder) { + for (SemanticsNode child : childrenInTraversalOrder) { child.log(childIndent, recursive); } } } - void updateWith(ByteBuffer buffer, String[] strings) { + private void updateWith(@NonNull ByteBuffer buffer, @NonNull String[] strings) { hadPreviousConfig = true; previousValue = value; previousLabel = label; @@ -1190,7 +1793,7 @@ void updateWith(ByteBuffer buffer, String[] strings) { childrenInTraversalOrder.clear(); for (int i = 0; i < childCount; ++i) { - SemanticsObject child = getOrCreateObject(buffer.getInt()); + SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt()); child.parent = this; childrenInTraversalOrder.add(child); } @@ -1201,7 +1804,7 @@ void updateWith(ByteBuffer buffer, String[] strings) { childrenInHitTestOrder.clear(); for (int i = 0; i < childCount; ++i) { - SemanticsObject child = getOrCreateObject(buffer.getInt()); + SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt()); child.parent = this; childrenInHitTestOrder.add(child); } @@ -1216,7 +1819,7 @@ void updateWith(ByteBuffer buffer, String[] strings) { customAccessibilityActions.clear(); for (int i = 0; i < actionCount; i++) { - CustomAccessibilityAction action = getOrCreateAction(buffer.getInt()); + CustomAccessibilityAction action = accessibilityBridge.getOrCreateAccessibilityAction(buffer.getInt()); if (action.overrideId == Action.TAP.value) { onTapOverride = action; } else if (action.overrideId == Action.LONG_PRESS.value) { @@ -1245,12 +1848,12 @@ private void ensureInverseTransform() { } } - Rect getGlobalRect() { + private Rect getGlobalRect() { assert !globalGeometryDirty; return globalRect; } - SemanticsObject hitTest(float[] point) { + private SemanticsNode hitTest(float[] point) { final float w = point[3]; final float x = point[0] / w; final float y = point[1] / w; @@ -1258,13 +1861,13 @@ SemanticsObject hitTest(float[] point) { if (childrenInHitTestOrder != null) { final float[] transformedPoint = new float[4]; for (int i = 0; i < childrenInHitTestOrder.size(); i += 1) { - final SemanticsObject child = childrenInHitTestOrder.get(i); + final SemanticsNode child = childrenInHitTestOrder.get(i); if (child.hasFlag(Flag.IS_HIDDEN)) { continue; } child.ensureInverseTransform(); Matrix.multiplyMV(transformedPoint, 0, child.inverseTransform, 0, point, 0); - final SemanticsObject result = child.hitTest(transformedPoint); + final SemanticsNode result = child.hitTest(transformedPoint); if (result != null) { return result; } @@ -1275,7 +1878,7 @@ SemanticsObject hitTest(float[] point) { // TODO(goderbauer): This should be decided by the framework once we have more information // about focusability there. - boolean isFocusable() { + private boolean isFocusable() { // We enforce in the framework that no other useful semantics are merged with these // nodes. if (hasFlag(Flag.SCOPES_ROUTE)) { @@ -1288,7 +1891,7 @@ boolean isFocusable() { || (hint != null && !hint.isEmpty()); } - void collectRoutes(List edges) { + private void collectRoutes(List edges) { if (hasFlag(Flag.SCOPES_ROUTE)) { edges.add(this); } @@ -1299,7 +1902,7 @@ void collectRoutes(List edges) { } } - String getRouteName() { + private String getRouteName() { // Returns the first non-null and non-empty semantic label of a child // with an NamesRoute flag. Otherwise returns null. if (hasFlag(Flag.NAMES_ROUTE)) { @@ -1318,7 +1921,7 @@ String getRouteName() { return null; } - void updateRecursively(float[] ancestorTransform, Set visitedObjects, + private void updateRecursively(float[] ancestorTransform, Set visitedObjects, boolean forceUpdate) { visitedObjects.add(this); diff --git a/shell/platform/android/io/flutter/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index 6161a23c5f4af..18434c28924aa 100644 --- a/shell/platform/android/io/flutter/view/FlutterView.java +++ b/shell/platform/android/io/flutter/view/FlutterView.java @@ -8,12 +8,10 @@ import android.app.Activity; import android.content.Context; import android.content.res.Configuration; -import android.database.ContentObserver; import android.graphics.Bitmap; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.SurfaceTexture; -import android.net.Uri; import android.os.Build; import android.os.Handler; import android.os.LocaleList; @@ -52,8 +50,7 @@ /** * An Android view containing a Flutter app. */ -public class FlutterView extends SurfaceView - implements BinaryMessenger, TextureRegistry, AccessibilityManager.AccessibilityStateChangeListener { +public class FlutterView extends SurfaceView implements BinaryMessenger, TextureRegistry { /** * Interface for those objects that maintain and expose a reference to a * {@code FlutterView} (such as a full-screen Flutter activity). @@ -91,7 +88,6 @@ static final class ViewportMetrics { } private final DartExecutor dartExecutor; - private final AccessibilityChannel accessibilityChannel; private final NavigationChannel navigationChannel; private final KeyEventChannel keyEventChannel; private final LifecycleChannel lifecycleChannel; @@ -105,14 +101,19 @@ static final class ViewportMetrics { private AccessibilityBridge mAccessibilityNodeProvider; private final SurfaceHolder.Callback mSurfaceCallback; private final ViewportMetrics mMetrics; - private final AccessibilityManager mAccessibilityManager; private final List mActivityLifecycleListeners; private final List mFirstFrameListeners; private final AtomicLong nextTextureId = new AtomicLong(0L); private FlutterNativeView mNativeView; - private final AnimationScaleObserver mAnimationScaleObserver; private boolean mIsSoftwareRenderingEnabled = false; // using the software renderer or not + private final AccessibilityBridge.OnAccessibilityChangeListener onAccessibilityChangeListener = new AccessibilityBridge.OnAccessibilityChangeListener() { + @Override + public void onAccessibilityChanged(boolean isAccessibilityEnabled, boolean isTouchExplorationEnabled) { + resetWillNotDraw(isAccessibilityEnabled, isTouchExplorationEnabled); + } + }; + public FlutterView(Context context) { this(context, null); } @@ -133,7 +134,6 @@ public FlutterView(Context context, AttributeSet attrs, FlutterNativeView native dartExecutor = mNativeView.getDartExecutor(); mIsSoftwareRenderingEnabled = FlutterJNI.nativeGetIsSoftwareRenderingEnabled(); - mAnimationScaleObserver = new AnimationScaleObserver(new Handler()); mMetrics = new ViewportMetrics(); mMetrics.devicePixelRatio = context.getResources().getDisplayMetrics().density; setFocusable(true); @@ -162,13 +162,10 @@ public void surfaceDestroyed(SurfaceHolder holder) { }; getHolder().addCallback(mSurfaceCallback); - mAccessibilityManager = (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); - mActivityLifecycleListeners = new ArrayList<>(); mFirstFrameListeners = new ArrayList<>(); // Create all platform channels - accessibilityChannel = new AccessibilityChannel(dartExecutor); navigationChannel = new NavigationChannel(dartExecutor); keyEventChannel = new KeyEventChannel(dartExecutor); lifecycleChannel = new LifecycleChannel(dartExecutor); @@ -236,7 +233,6 @@ public void onPause() { } public void onPostResume() { - updateAccessibilityFeatures(); for (ActivityLifecycleListener listener : mActivityLifecycleListeners) { listener.onPostResume(); } @@ -573,7 +569,7 @@ public boolean onHoverEvent(MotionEvent event) { return false; } - boolean handled = handleAccessibilityHoverEvent(event); + boolean handled = mAccessibilityNodeProvider.onAccessibilityHoverEvent(event); if (!handled) { // TODO(ianh): Expose hover events to the platform, // implementing ADD, REMOVE, etc. @@ -746,6 +742,12 @@ private void preRun() { resetAccessibilityTree(); } + void resetAccessibilityTree() { + if (mAccessibilityNodeProvider != null) { + mAccessibilityNodeProvider.reset(); + } + } + private void postRun() { } @@ -844,206 +846,52 @@ public void onFirstFrame() { } } - // ACCESSIBILITY - - private boolean mAccessibilityEnabled = false; - private boolean mTouchExplorationEnabled = false; - private int mAccessibilityFeatureFlags = 0; - private TouchExplorationListener mTouchExplorationListener; - - protected void dispatchSemanticsAction(int id, AccessibilityBridge.Action action) { - dispatchSemanticsAction(id, action, null); - } - - protected void dispatchSemanticsAction(int id, AccessibilityBridge.Action action, Object args) { - if (!isAttached()) - return; - ByteBuffer encodedArgs = null; - int position = 0; - if (args != null) { - encodedArgs = StandardMessageCodec.INSTANCE.encodeMessage(args); - position = encodedArgs.position(); - } - mNativeView.getFlutterJNI().dispatchSemanticsAction(id, action.value, encodedArgs, position); - } - @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); - mAccessibilityEnabled = mAccessibilityManager.isEnabled(); - mTouchExplorationEnabled = mAccessibilityManager.isTouchExplorationEnabled(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - Uri transitionUri = Settings.Global.getUriFor(Settings.Global.TRANSITION_ANIMATION_SCALE); - getContext().getContentResolver().registerContentObserver(transitionUri, false, mAnimationScaleObserver); - } - if (mAccessibilityEnabled || mTouchExplorationEnabled) { - ensureAccessibilityEnabled(); - } - if (mTouchExplorationEnabled) { - mAccessibilityFeatureFlags |= AccessibilityFeature.ACCESSIBLE_NAVIGATION.value; - } else { - mAccessibilityFeatureFlags &= ~AccessibilityFeature.ACCESSIBLE_NAVIGATION.value; - } - // Apply additional accessibility settings - updateAccessibilityFeatures(); - resetWillNotDraw(); - mAccessibilityManager.addAccessibilityStateChangeListener(this); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - if (mTouchExplorationListener == null) { - mTouchExplorationListener = new TouchExplorationListener(); - } - mAccessibilityManager.addTouchExplorationStateChangeListener(mTouchExplorationListener); - } - } + mAccessibilityNodeProvider = new AccessibilityBridge( + this, + getFlutterNativeView().getFlutterJNI(), + new AccessibilityChannel(dartExecutor), + (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE), + getContext().getContentResolver() + ); + mAccessibilityNodeProvider.setOnAccessibilityChangeListener(onAccessibilityChangeListener); - private void updateAccessibilityFeatures() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - String transitionAnimationScale = Settings.Global.getString(getContext().getContentResolver(), - Settings.Global.TRANSITION_ANIMATION_SCALE); - if (transitionAnimationScale != null && transitionAnimationScale.equals("0")) { - mAccessibilityFeatureFlags |= AccessibilityFeature.DISABLE_ANIMATIONS.value; - } else { - mAccessibilityFeatureFlags &= ~AccessibilityFeature.DISABLE_ANIMATIONS.value; - } - } - mNativeView.getFlutterJNI().setAccessibilityFeatures(mAccessibilityFeatureFlags); + resetWillNotDraw( + mAccessibilityNodeProvider.isAccessibilityEnabled(), + mAccessibilityNodeProvider.isTouchExplorationEnabled() + ); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); - getContext().getContentResolver().unregisterContentObserver(mAnimationScaleObserver); - mAccessibilityManager.removeAccessibilityStateChangeListener(this); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - mAccessibilityManager.removeTouchExplorationStateChangeListener(mTouchExplorationListener); - } + + mAccessibilityNodeProvider.release(); + mAccessibilityNodeProvider = null; } - private void resetWillNotDraw() { + // TODO(mattcarroll): Confer with Ian as to why we need this method. Delete if possible, otherwise add comments. + private void resetWillNotDraw(boolean isAccessibilityEnabled, boolean isTouchExplorationEnabled) { if (!mIsSoftwareRenderingEnabled) { - setWillNotDraw(!(mAccessibilityEnabled || mTouchExplorationEnabled)); + setWillNotDraw(!(isAccessibilityEnabled || isTouchExplorationEnabled)); } else { setWillNotDraw(false); } } - @Override - public void onAccessibilityStateChanged(boolean enabled) { - if (enabled) { - ensureAccessibilityEnabled(); - } else { - mAccessibilityEnabled = false; - if (mAccessibilityNodeProvider != null) { - mAccessibilityNodeProvider.setAccessibilityEnabled(false); - } - mNativeView.getFlutterJNI().setSemanticsEnabled(false); - } - resetWillNotDraw(); - } - - /// 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); - - AccessibilityFeature(int value) { - this.value = value; - } - - final int value; - } - - // Listens to the global TRANSITION_ANIMATION_SCALE property and notifies us so - // that we can disable animations in Flutter. - private class AnimationScaleObserver extends ContentObserver { - public AnimationScaleObserver(Handler handler) { - super(handler); - } - - @Override - public void onChange(boolean selfChange) { - this.onChange(selfChange, null); - } - - @Override - public void onChange(boolean selfChange, Uri uri) { - String value = Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1 ? null - : Settings.Global.getString(getContext().getContentResolver(), - Settings.Global.TRANSITION_ANIMATION_SCALE); - if (value != null && value.equals("0")) { - mAccessibilityFeatureFlags |= AccessibilityFeature.DISABLE_ANIMATIONS.value; - } else { - mAccessibilityFeatureFlags &= ~AccessibilityFeature.DISABLE_ANIMATIONS.value; - } - mNativeView.getFlutterJNI().setAccessibilityFeatures(mAccessibilityFeatureFlags); - } - } - - // This is guarded at instantiation time. - @TargetApi(19) - @RequiresApi(19) - class TouchExplorationListener implements AccessibilityManager.TouchExplorationStateChangeListener { - @Override - public void onTouchExplorationStateChanged(boolean enabled) { - if (enabled) { - mTouchExplorationEnabled = true; - ensureAccessibilityEnabled(); - mAccessibilityFeatureFlags |= AccessibilityFeature.ACCESSIBLE_NAVIGATION.value; - mNativeView.getFlutterJNI().setAccessibilityFeatures(mAccessibilityFeatureFlags); - } else { - mTouchExplorationEnabled = false; - if (mAccessibilityNodeProvider != null) { - mAccessibilityNodeProvider.handleTouchExplorationExit(); - } - mAccessibilityFeatureFlags &= ~AccessibilityFeature.ACCESSIBLE_NAVIGATION.value; - mNativeView.getFlutterJNI().setAccessibilityFeatures(mAccessibilityFeatureFlags); - } - resetWillNotDraw(); - } - } - @Override public AccessibilityNodeProvider getAccessibilityNodeProvider() { - if (mAccessibilityEnabled) + if (mAccessibilityNodeProvider.isAccessibilityEnabled()) { return mAccessibilityNodeProvider; - // TODO(goderbauer): when a11y is off this should return a one-off snapshot of - // the a11y - // tree. - return null; - } - - void ensureAccessibilityEnabled() { - if (!isAttached()) - return; - mAccessibilityEnabled = true; - if (mAccessibilityNodeProvider == null) { - mAccessibilityNodeProvider = new AccessibilityBridge(this, accessibilityChannel); - } - mNativeView.getFlutterJNI().setSemanticsEnabled(true); - mAccessibilityNodeProvider.setAccessibilityEnabled(true); - } - - void resetAccessibilityTree() { - if (mAccessibilityNodeProvider != null) { - mAccessibilityNodeProvider.reset(); - } - } - - private boolean handleAccessibilityHoverEvent(MotionEvent event) { - if (!mTouchExplorationEnabled) { - return false; - } - if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER || event.getAction() == MotionEvent.ACTION_HOVER_MOVE) { - mAccessibilityNodeProvider.handleTouchExploration(event.getX(), event.getY()); - } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { - mAccessibilityNodeProvider.handleTouchExplorationExit(); } else { - Log.d("flutter", "unexpected accessibility hover event: " + event); - return false; + // TODO(goderbauer): when a11y is off this should return a one-off snapshot of + // the a11y + // tree. + return null; } - return true; } @Override diff --git a/tools/android_lint/baseline.xml b/tools/android_lint/baseline.xml index 93bc535f5737d..8bf5af53ea8c0 100644 --- a/tools/android_lint/baseline.xml +++ b/tools/android_lint/baseline.xml @@ -4,22 +4,22 @@ @@ -30,18 +30,18 @@ errorLine2=" ~~~~~~"> @@ -52,7 +52,7 @@ errorLine2=" ~~~~~~"> @@ -63,7 +63,7 @@ errorLine2=" ~~~~~~"> @@ -74,29 +74,29 @@ errorLine2=" ~~~~~~"> @@ -107,7 +107,7 @@ errorLine2=" ~~~~~~"> @@ -118,7 +118,7 @@ errorLine2=" ~~~~~~"> @@ -129,7 +129,7 @@ errorLine2=" ~~~~~~"> @@ -305,7 +305,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -327,7 +327,7 @@ errorLine2=" ^"> @@ -360,7 +360,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -371,7 +371,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -432,24 +432,24 @@ + message="Use `new SparseArray<SemanticsNode>(...)` instead for better performance" + errorLine1=" private final Map<Integer, SemanticsNode> flutterSemanticsTree = new HashMap<>();" + errorLine2=" ~~~~~~~~~~~~~~~"> + line="135" + column="70"/> + errorLine1=" private final Map<Integer, CustomAccessibilityAction> customAccessibilityActions = new HashMap<>();" + errorLine2=" ~~~~~~~~~~~~~~~"> + line="160" + column="88"/>