From 45442ef3a0ef088111efb0e116670fbf2fb40644 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 6 Mar 2019 13:41:44 -0800 Subject: [PATCH 1/5] Android Embedding PR 17: Clarify AccessibilityBridge. --- .../flutter/embedding/engine/FlutterJNI.java | 18 + .../systemchannels/AccessibilityChannel.java | 4 +- .../io/flutter/view/AccessibilityBridge.java | 395 +++++++++++------- .../android/io/flutter/view/FlutterView.java | 22 +- 4 files changed, 267 insertions(+), 172 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java b/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java index f2d194275c3d4..f9bd7b47ed639 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) { + ensureNotAttachedToNative(); + + 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..ea040ca740407 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java @@ -103,12 +103,12 @@ 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); diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index ac5e2f8f3d995..424a367db7c80 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -10,21 +10,49 @@ import android.os.Build; import android.os.Bundle; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.util.Log; import android.view.View; import android.view.accessibility.AccessibilityEvent; 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 a real Android {@link View}, called the + * {@link #rootAccessibilityView}, which contains a Flutter UI. The {@link #rootAccessibilityView} + * is required at the time of {@code AccessibilityBridge}'s instantiation and is held + * for the duration of {@code AccessibilityBridge}'s lifespan. {@code AccessibilityBridge} + * invokes various accessibility methods on the {@link #rootAccessibilityView}, e.g., + * {@link View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)}. The + * {@link #rootAccessibilityView} is expected to notify the {@code AccessibilityBridge} + * of relevant interactions: {@link #setAccessibilityEnabled(boolean)}, + * {@link #handleTouchExploration(float, float)}, {@link #handleTouchExplorationExit()}, + * and {@link #reset()}. + * + * The {@code AccessibilityBridge} tricks Android into treating Flutter widgets 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): why do we use both? + */ +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 @@ -35,35 +63,94 @@ 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; + + // The top-level Android View within the containing Window. + // TODO(mattcarroll): why do we need this? and why are we caching it? + @NonNull private final View decorView; - private Map objects; - private Map customAccessibilityActions; + + // TODO(mattcarroll): what exactly is a semantic object, and why do we cache them? + @NonNull + private final Map semanticsObjects; + + // TODO(mattcarroll): what makes a CustomAccessibilityAction different than a regular + // AccessibilityAction. why do we cache them? + @NonNull + private final Map customAccessibilityActions; + + // TODO(mattcarroll): what is the source of truth for this supposed to be? Is it + // whether or not the OS has accessibility enabled? Or do other + // factors influence this value? private boolean accessibilityEnabled = false; - private SemanticsObject a11yFocusedObject; + + // The widget within Flutter that currently has the focus of Android's + // accessibility system. + @Nullable + private SemanticsObject accessibilityFocusedObject; + + // The widget within Flutter that currently has the focus of Android's input + // system. + // TODO(mattcarroll): is there a relationship between this and the InputMethodManager? + @Nullable private SemanticsObject inputFocusedObject; + + // The widget within Flutter that currently sits beneath a cursor, e.g, + // beneath a stylus or mouse cursor. + @Nullable private SemanticsObject hoveredObject; + private int previousRouteId = ROOT_NODE_ID; - private List previousRoutes; + + @NonNull + private final List previousRoutes; + + @NonNull private Integer lastLeftFrameInset = 0; + // 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 popup window, menu, dialog, etc. + */ @Override public void onTooltip(@NonNull String message) { AccessibilityEvent e = obtainAccessibilityEvent(ROOT_NODE_ID, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); @@ -72,69 +159,12 @@ 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), - 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); - - Action(int value) { - this.value = value; - } - - final int value; - } - - 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); - - Flag(int value) { - this.value = value; - } - - final int value; - } - - AccessibilityBridge(@NonNull FlutterView owner, @NonNull AccessibilityChannel accessibilityChannel) { - this.owner = owner; + AccessibilityBridge(@NonNull View rootAccessibilityView, @NonNull FlutterJNI flutterJNI, @NonNull AccessibilityChannel accessibilityChannel) { + this.rootAccessibilityView = rootAccessibilityView; + this.flutterJNI = flutterJNI; this.accessibilityChannel = accessibilityChannel; - decorView = ((Activity) owner.getContext()).getWindow().getDecorView(); - objects = new HashMap<>(); + decorView = ((Activity) rootAccessibilityView.getContext()).getWindow().getDecorView(); + semanticsObjects = new HashMap<>(); customAccessibilityActions = new HashMap<>(); previousRoutes = new ArrayList<>(); } @@ -159,42 +189,46 @@ private boolean shouldSetCollectionInfo(final SemanticsObject object) { // 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))); + && (hasSemanticsObjectAncestor(accessibilityFocusedObject, o -> o == object) + || !hasSemanticsObjectAncestor(accessibilityFocusedObject, o -> o.hasFlag(Flag.HAS_IMPLICIT_SCROLLING))); + } + + private boolean hasSemanticsObjectAncestor(SemanticsObject target, Predicate tester) { + return target != null && target.getAncestor(tester) != null; } @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); + if (semanticsObjects.containsKey(ROOT_NODE_ID)) { + result.addChild(rootAccessibilityView, ROOT_NODE_ID); } return result; } - SemanticsObject object = objects.get(virtualViewId); + SemanticsObject object = semanticsObjects.get(virtualViewId); if (object == 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.setSource(rootAccessibilityView, virtualViewId); result.setFocusable(object.isFocusable()); if (inputFocusedObject != null) { result.setFocused(inputFocusedObject.id == virtualViewId); } - if (a11yFocusedObject != null) { - result.setAccessibilityFocused(a11yFocusedObject.id == virtualViewId); + if (accessibilityFocusedObject != null) { + result.setAccessibilityFocused(accessibilityFocusedObject.id == virtualViewId); } if (object.hasFlag(Flag.IS_TEXT_FIELD)) { @@ -208,7 +242,7 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { // 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 && accessibilityFocusedObject != null && accessibilityFocusedObject.id == virtualViewId) { result.setLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); } } @@ -261,10 +295,10 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { if (object.parent != null) { assert object.id > ROOT_NODE_ID; - result.setParent(owner, object.parent.id); + result.setParent(rootAccessibilityView, object.parent.id); } else { assert object.id == ROOT_NODE_ID; - result.setParent(owner); + result.setParent(rootAccessibilityView); } Rect bounds = object.getGlobalRect(); @@ -384,7 +418,7 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { result.setSelected(object.hasFlag(Flag.IS_SELECTED)); // Accessibility Focus - if (a11yFocusedObject != null && a11yFocusedObject.id == virtualViewId) { + if (accessibilityFocusedObject != null && accessibilityFocusedObject.id == virtualViewId) { result.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); } else { result.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); @@ -403,7 +437,7 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { if (object.childrenInTraversalOrder != null) { for (SemanticsObject child : object.childrenInTraversalOrder) { if (!child.hasFlag(Flag.IS_HIDDEN)) { - result.addChild(owner, child.id); + result.addChild(rootAccessibilityView, child.id); } } } @@ -413,7 +447,7 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { @Override public boolean performAction(int virtualViewId, int action, Bundle arguments) { - SemanticsObject object = objects.get(virtualViewId); + SemanticsObject object = semanticsObjects.get(virtualViewId); if (object == null) { return false; } @@ -422,27 +456,27 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { // 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); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_UP); } else if (object.hasAction(Action.SCROLL_LEFT)) { // TODO(ianh): bidi support using textDirection - owner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_LEFT); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_LEFT); } else if (object.hasAction(Action.INCREASE)) { object.value = object.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; } @@ -450,15 +484,15 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { } case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: { if (object.hasAction(Action.SCROLL_DOWN)) { - owner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_DOWN); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_DOWN); } else if (object.hasAction(Action.SCROLL_RIGHT)) { // TODO(ianh): bidi support using textDirection - owner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_RIGHT); + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_RIGHT); } else if (object.hasAction(Action.DECREASE)) { object.value = object.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; } @@ -471,24 +505,24 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { return performCursorMoveAction(object, 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; + accessibilityFocusedObject = 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); - if (a11yFocusedObject == null) { + if (accessibilityFocusedObject == 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; + accessibilityFocusedObject = object; if (object.hasAction(Action.INCREASE) || object.hasAction(Action.DECREASE)) { // SeekBars only announce themselves after this event. @@ -498,7 +532,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: { @@ -520,32 +554,32 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { selection.put("base", object.textSelectionExtent); selection.put("extent", object.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; + final int flutterId = action - FIRST_RESOURCE_ID; CustomAccessibilityAction contextAction = customAccessibilityActions.get(flutterId); if (contextAction != null) { - owner.dispatchSemanticsAction( + flutterJNI.dispatchSemanticsAction( virtualViewId, Action.CUSTOM_ACTION, contextAction.id); return true; } @@ -562,12 +596,12 @@ boolean performCursorMoveAction( switch (granularity) { case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: { if (forward && object.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) { - owner.dispatchSemanticsAction(virtualViewId, + 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, + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER, extendSelection); return true; } @@ -575,12 +609,12 @@ boolean performCursorMoveAction( } case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD: if (forward && object.hasAction(Action.MOVE_CURSOR_FORWARD_BY_WORD)) { - owner.dispatchSemanticsAction(virtualViewId, + 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, + flutterJNI.dispatchSemanticsAction(virtualViewId, Action.MOVE_CURSOR_BACKWARD_BY_WORD, extendSelection); return true; } @@ -600,24 +634,24 @@ public AccessibilityNodeInfo findFocus(int focus) { } // Fall through to check FOCUS_ACCESSIBILITY case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY: { - if (a11yFocusedObject != null) - return createAccessibilityNodeInfo(a11yFocusedObject.id); + if (accessibilityFocusedObject != null) + return createAccessibilityNodeInfo(accessibilityFocusedObject.id); } } return null; } private SemanticsObject getRootObject() { - assert objects.containsKey(0); - return objects.get(0); + assert semanticsObjects.containsKey(0); + return semanticsObjects.get(0); } private SemanticsObject getOrCreateObject(int id) { - SemanticsObject object = objects.get(id); + SemanticsObject object = semanticsObjects.get(id); if (object == null) { - object = new SemanticsObject(); + object = new SemanticsObject(this); object.id = id; - objects.put(id, object); + semanticsObjects.put(id, object); } return object; } @@ -627,7 +661,7 @@ private CustomAccessibilityAction getOrCreateAction(int 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; @@ -641,7 +675,7 @@ void handleTouchExplorationExit() { } void handleTouchExploration(float x, float y) { - if (objects.isEmpty()) { + if (semanticsObjects.isEmpty()) { return; } SemanticsObject newObject = getRootObject().hitTest(new float[] {x, y, 0, 1}); @@ -729,7 +763,7 @@ void updateSemantics(ByteBuffer buffer, String[] strings) { previousRoutes.add(semanticsObject.id); } - Iterator> it = objects.entrySet().iterator(); + Iterator> it = semanticsObjects.entrySet().iterator(); while (it.hasNext()) { Map.Entry entry = it.next(); SemanticsObject object = entry.getValue(); @@ -814,7 +848,7 @@ void updateSemantics(ByteBuffer buffer, String[] strings) { // 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 (accessibilityFocusedObject != null && accessibilityFocusedObject.id == object.id && !object.hadFlag(Flag.IS_SELECTED) && object.hasFlag(Flag.IS_SELECTED)) { AccessibilityEvent event = obtainAccessibilityEvent(object.id, AccessibilityEvent.TYPE_VIEW_SELECTED); @@ -827,7 +861,7 @@ void updateSemantics(ByteBuffer buffer, String[] strings) { // 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))) { + && (accessibilityFocusedObject == null || (accessibilityFocusedObject.id == inputFocusedObject.id))) { String oldValue = object.previousValue != null ? object.previousValue : ""; String newValue = object.value != null ? object.value : ""; AccessibilityEvent event = createTextChangedEvent(object.id, oldValue, newValue); @@ -885,8 +919,8 @@ private AccessibilityEvent createTextChangedEvent(int id, String oldValue, Strin 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); + event.setPackageName(rootAccessibilityView.getContext().getPackageName()); + event.setSource(rootAccessibilityView, virtualViewId); return event; } @@ -895,7 +929,7 @@ private void sendAccessibilityEvent(int virtualViewId, int eventType) { return; } if (virtualViewId == ROOT_NODE_ID) { - owner.sendAccessibilityEvent(eventType); + rootAccessibilityView.sendAccessibilityEvent(eventType); } else { sendAccessibilityEvent(obtainAccessibilityEvent(virtualViewId, eventType)); } @@ -905,7 +939,7 @@ private void sendAccessibilityEvent(AccessibilityEvent event) { if (!accessibilityEnabled) { return; } - owner.getParent().requestSendAccessibilityEvent(owner, event); + rootAccessibilityView.getParent().requestSendAccessibilityEvent(rootAccessibilityView, event); } private void createWindowChangeEvent(SemanticsObject route) { @@ -917,13 +951,13 @@ private void createWindowChangeEvent(SemanticsObject route) { } private void willRemoveSemanticsObject(SemanticsObject object) { - assert objects.containsKey(object.id); - assert objects.get(object.id) == object; + assert semanticsObjects.containsKey(object.id); + assert semanticsObjects.get(object.id) == object; object.parent = null; - if (a11yFocusedObject == object) { - sendAccessibilityEvent(a11yFocusedObject.id, + if (accessibilityFocusedObject == object) { + sendAccessibilityEvent(accessibilityFocusedObject.id, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); - a11yFocusedObject = null; + accessibilityFocusedObject = null; } if (inputFocusedObject == object) { inputFocusedObject = null; @@ -934,15 +968,73 @@ private void willRemoveSemanticsObject(SemanticsObject object) { } void reset() { - objects.clear(); - if (a11yFocusedObject != null) - sendAccessibilityEvent(a11yFocusedObject.id, + semanticsObjects.clear(); + if (accessibilityFocusedObject != null) + sendAccessibilityEvent(accessibilityFocusedObject.id, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); - a11yFocusedObject = null; + accessibilityFocusedObject = null; hoveredObject = null; sendAccessibilityEvent(0, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); } + 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); + + Action(int value) { + this.value = value; + } + + public final int value; + } + + 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); + + Flag(int value) { + this.value = value; + } + + final int value; + } + private enum TextDirection { UNKNOWN, LTR, @@ -959,7 +1051,7 @@ public static TextDirection fromInt(int value) { } } - private class CustomAccessibilityAction { + private static class CustomAccessibilityAction { CustomAccessibilityAction() {} /// Resource id is the id of the custom action plus a minimum value so that the identifier @@ -978,16 +1070,9 @@ boolean isStandardAction() { return overrideId != -1; } } - /// Value is derived from ACTION_TYPE_MASK in AccessibilityNodeInfo.java - static int firstResourceId = 267386881; - - - static boolean hasSemanticsObjectAncestor(SemanticsObject target, Predicate tester) { - return target != null && target.getAncestor(tester) != null; - } - private class SemanticsObject { - SemanticsObject() {} + private static class SemanticsObject { + final AccessibilityBridge accessibilityBridge; int id = -1; @@ -1038,6 +1123,10 @@ private class SemanticsObject { private float[] globalTransform; private Rect globalRect; + SemanticsObject(@NonNull AccessibilityBridge accessibilityBridge) { + this.accessibilityBridge = accessibilityBridge; + } + SemanticsObject getAncestor(Predicate tester) { SemanticsObject nextAncestor = parent; while (nextAncestor != null) { @@ -1157,7 +1246,7 @@ void updateWith(ByteBuffer buffer, String[] strings) { childrenInTraversalOrder.clear(); for (int i = 0; i < childCount; ++i) { - SemanticsObject child = getOrCreateObject(buffer.getInt()); + SemanticsObject child = accessibilityBridge.getOrCreateObject(buffer.getInt()); child.parent = this; childrenInTraversalOrder.add(child); } @@ -1168,7 +1257,7 @@ void updateWith(ByteBuffer buffer, String[] strings) { childrenInHitTestOrder.clear(); for (int i = 0; i < childCount; ++i) { - SemanticsObject child = getOrCreateObject(buffer.getInt()); + SemanticsObject child = accessibilityBridge.getOrCreateObject(buffer.getInt()); child.parent = this; childrenInHitTestOrder.add(child); } @@ -1183,7 +1272,7 @@ void updateWith(ByteBuffer buffer, String[] strings) { customAccessibilityActions.clear(); for (int i = 0; i < actionCount; i++) { - CustomAccessibilityAction action = getOrCreateAction(buffer.getInt()); + CustomAccessibilityAction action = accessibilityBridge.getOrCreateAction(buffer.getInt()); if (action.overrideId == Action.TAP.value) { onTapOverride = action; } else if (action.overrideId == Action.LONG_PRESS.value) { diff --git a/shell/platform/android/io/flutter/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index 6c42dc152d739..5b3cd701169df 100644 --- a/shell/platform/android/io/flutter/view/FlutterView.java +++ b/shell/platform/android/io/flutter/view/FlutterView.java @@ -846,22 +846,6 @@ public void onFirstFrame() { 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(); @@ -1010,7 +994,11 @@ void ensureAccessibilityEnabled() { return; mAccessibilityEnabled = true; if (mAccessibilityNodeProvider == null) { - mAccessibilityNodeProvider = new AccessibilityBridge(this, accessibilityChannel); + mAccessibilityNodeProvider = new AccessibilityBridge( + this, + getFlutterNativeView().getFlutterJNI(), + accessibilityChannel + ); } mNativeView.getFlutterJNI().setSemanticsEnabled(true); mAccessibilityNodeProvider.setAccessibilityEnabled(true); From 149fce718906cdf07000e1458b41f43aeeb4ae0d Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 6 Mar 2019 18:12:58 -0800 Subject: [PATCH 2/5] Comments round 2 --- .../systemchannels/AccessibilityChannel.java | 2 +- .../io/flutter/view/AccessibilityBridge.java | 825 ++++++++++++------ 2 files changed, 536 insertions(+), 291 deletions(-) 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 ea040ca740407..7eed2619bebda 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java @@ -113,7 +113,7 @@ public interface AccessibilityMessageHandler { 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 424a367db7c80..4eb9f5ed33c42 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -80,16 +80,55 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { private final AccessibilityChannel accessibilityChannel; // The top-level Android View within the containing Window. - // TODO(mattcarroll): why do we need this? and why are we caching it? + // 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; - // TODO(mattcarroll): what exactly is a semantic object, and why do we cache them? + // 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 semanticsObjects; - - // TODO(mattcarroll): what makes a CustomAccessibilityAction different than a regular - // AccessibilityAction. why do we cache them? + private final Map flutterSemanticsTree; + + // 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; @@ -98,27 +137,37 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { // factors influence this value? private boolean accessibilityEnabled = false; - // The widget within Flutter that currently has the focus of Android's + // The {@code SemanticsNode} within Flutter that currently has the focus of Android's // accessibility system. @Nullable - private SemanticsObject accessibilityFocusedObject; + private SemanticsNode accessibilityFocusedSemanticsNode; - // The widget within Flutter that currently has the focus of Android's input + // The {@code SemanticsNode} within Flutter that currently has the focus of Android's input // system. - // TODO(mattcarroll): is there a relationship between this and the InputMethodManager? + // + // 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 SemanticsObject inputFocusedObject; + private SemanticsNode inputFocusedSemanticsNode; // The widget within Flutter that currently sits beneath a cursor, e.g, // beneath a stylus or mouse cursor. @Nullable - private SemanticsObject hoveredObject; + private SemanticsNode hoveredObject; + // TODO(mattcarroll): what is this? the term "route" sounds navigation oriented... private int previousRouteId = ROOT_NODE_ID; + // TODO(mattcarroll): what is this? the term "route" sounds navigation oriented... @NonNull private final List previousRoutes; + // TODO(mattcarroll): is this for the decor view adjustment? @NonNull private Integer lastLeftFrameInset = 0; @@ -149,7 +198,7 @@ public void onLongPress(int nodeId) { } /** - * The user has opened a popup window, menu, dialog, etc. + * The user has opened a tooltip. */ @Override public void onTooltip(@NonNull String message) { @@ -164,7 +213,7 @@ public void onTooltip(@NonNull String message) { this.flutterJNI = flutterJNI; this.accessibilityChannel = accessibilityChannel; decorView = ((Activity) rootAccessibilityView.getContext()).getWindow().getDecorView(); - semanticsObjects = new HashMap<>(); + flutterSemanticsTree = new HashMap<>(); customAccessibilityActions = new HashMap<>(); previousRoutes = new ArrayList<>(); } @@ -178,39 +227,58 @@ void setAccessibilityEnabled(boolean accessibilityEnabled) { } } - private boolean shouldSetCollectionInfo(final SemanticsObject object) { + 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(accessibilityFocusedObject, o -> o == object) - || !hasSemanticsObjectAncestor(accessibilityFocusedObject, o -> o.hasFlag(Flag.HAS_IMPLICIT_SCROLLING))); - } - - private boolean hasSemanticsObjectAncestor(SemanticsObject target, Predicate tester) { - return target != null && target.getAncestor(tester) != null; + 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(rootAccessibilityView); rootAccessibilityView.onInitializeAccessibilityNodeInfo(result); - if (semanticsObjects.containsKey(ROOT_NODE_ID)) { + // 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 = semanticsObjects.get(virtualViewId); - if (object == null) { + SemanticsNode semanticsNode = flutterSemanticsTree.get(virtualViewId); + if (semanticsNode == null) { return null; } @@ -222,88 +290,88 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { result.setPackageName(rootAccessibilityView.getContext().getPackageName()); result.setClassName("android.view.View"); result.setSource(rootAccessibilityView, virtualViewId); - result.setFocusable(object.isFocusable()); - if (inputFocusedObject != null) { - result.setFocused(inputFocusedObject.id == virtualViewId); + result.setFocusable(semanticsNode.isFocusable()); + if (inputFocusedSemanticsNode != null) { + result.setFocused(inputFocusedSemanticsNode.id == virtualViewId); } - if (accessibilityFocusedObject != null) { - result.setAccessibilityFocused(accessibilityFocusedObject.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 && accessibilityFocusedObject != null && accessibilityFocusedObject.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; } result.setMovementGranularities(granularities); } - 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(rootAccessibilityView, 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; + 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); @@ -313,30 +381,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 @@ -348,22 +421,24 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { // can fallback to the generic scroll view class names. // 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 (shouldSetCollectionInfo(object)) { + if (semanticsNode.hasFlag(Flag.HAS_IMPLICIT_SCROLLING)) { + if (semanticsNode.hasAction(Action.SCROLL_LEFT) || semanticsNode.hasAction(Action.SCROLL_RIGHT)) { + if (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 (shouldSetCollectionInfo(object)) { + if (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"); } @@ -372,53 +447,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 (accessibilityFocusedObject != null && accessibilityFocusedObject.id == virtualViewId) { + if (accessibilityFocusedSemanticsNode != null && accessibilityFocusedSemanticsNode.id == virtualViewId) { result.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); } else { result.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); @@ -426,16 +502,18 @@ 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(rootAccessibilityView, child.id); } @@ -445,13 +523,27 @@ 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 = semanticsObjects.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 @@ -467,13 +559,13 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { return true; } case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: { - if (object.hasAction(Action.SCROLL_UP)) { + if (semanticsNode.hasAction(Action.SCROLL_UP)) { flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_UP); - } else if (object.hasAction(Action.SCROLL_LEFT)) { + } else if (semanticsNode.hasAction(Action.SCROLL_LEFT)) { // TODO(ianh): bidi support using textDirection flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_LEFT); - } else if (object.hasAction(Action.INCREASE)) { - object.value = object.increasedValue; + } 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); flutterJNI.dispatchSemanticsAction(virtualViewId, Action.INCREASE); @@ -483,13 +575,13 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { return true; } case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: { - if (object.hasAction(Action.SCROLL_DOWN)) { + if (semanticsNode.hasAction(Action.SCROLL_DOWN)) { flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_DOWN); - } else if (object.hasAction(Action.SCROLL_RIGHT)) { + } else if (semanticsNode.hasAction(Action.SCROLL_RIGHT)) { // TODO(ianh): bidi support using textDirection flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SCROLL_RIGHT); - } else if (object.hasAction(Action.DECREASE)) { - object.value = object.decreasedValue; + } 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); flutterJNI.dispatchSemanticsAction(virtualViewId, Action.DECREASE); @@ -499,32 +591,42 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { return true; } case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: { - return performCursorMoveAction(object, virtualViewId, arguments, false); + return performCursorMoveAction(semanticsNode, virtualViewId, arguments, false); } case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: { - return performCursorMoveAction(object, virtualViewId, arguments, true); + return performCursorMoveAction(semanticsNode, virtualViewId, arguments, true); } case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS: { - flutterJNI.dispatchSemanticsAction(virtualViewId, Action.DID_LOSE_ACCESSIBILITY_FOCUS); + flutterJNI.dispatchSemanticsAction( + virtualViewId, + Action.DID_LOSE_ACCESSIBILITY_FOCUS + ); sendAccessibilityEvent( - virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); - accessibilityFocusedObject = null; + virtualViewId, + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED + ); + accessibilityFocusedSemanticsNode = null; return true; } case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: { - flutterJNI.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 (accessibilityFocusedObject == 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.) rootAccessibilityView.invalidate(); } - accessibilityFocusedObject = 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); } @@ -538,21 +640,21 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { case AccessibilityNodeInfo.ACTION_SET_SELECTION: { 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); } flutterJNI.dispatchSemanticsAction(virtualViewId, Action.SET_SELECTION, selection); return true; @@ -574,48 +676,72 @@ public boolean performAction(int virtualViewId, int action, Bundle arguments) { return true; } default: - // might be a custom accessibility action. - final int flutterId = action - FIRST_RESOURCE_ID; - 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) { flutterJNI.dispatchSemanticsAction( - virtualViewId, Action.CUSTOM_ACTION, contextAction.id); + virtualViewId, + Action.CUSTOM_ACTION, + contextAction.id + ); return true; } } return false; } - boolean performCursorMoveAction( - SemanticsObject object, int virtualViewId, Bundle arguments, boolean forward) { - final int granularity = - arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); + /** + * Handles the responsibilities of {@link #performAction(int, int, Bundle)} for the specific + * scenario of cursor movement. + */ + 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)) { - flutterJNI.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)) { - flutterJNI.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)) { - flutterJNI.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)) { - flutterJNI.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; @@ -625,38 +751,77 @@ 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 (accessibilityFocusedObject != null) - return createAccessibilityNodeInfo(accessibilityFocusedObject.id); + if (accessibilityFocusedSemanticsNode != null) { + return createAccessibilityNodeInfo(accessibilityFocusedSemanticsNode.id); + } } } return null; } - private SemanticsObject getRootObject() { - assert semanticsObjects.containsKey(0); - return semanticsObjects.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 = semanticsObjects.get(id); - if (object == null) { - object = new SemanticsObject(this); - object.id = id; - semanticsObjects.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(); @@ -675,10 +840,10 @@ void handleTouchExplorationExit() { } void handleTouchExploration(float x, float y) { - if (semanticsObjects.isEmpty()) { + if (flutterSemanticsTree.isEmpty()) { return; } - SemanticsObject newObject = getRootObject().hitTest(new float[] {x, y, 0, 1}); + SemanticsNode newObject = getRootSemanticsNode().hitTest(new float[] {x, y, 0, 1}); if (newObject != hoveredObject) { // sending ENTER before EXIT is how Android wants it if (newObject != null) { @@ -691,10 +856,15 @@ void handleTouchExploration(float x, float y) { } } - void updateCustomAccessibilityActions(ByteBuffer buffer, String[] strings) { + /** + * Updates the Android cache of Flutter's currently registered custom accessibility actions. + */ + // TODO(mattcarroll): how are custom actions deleted? this appears to only add them... + // 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]; @@ -703,26 +873,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); @@ -745,10 +921,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 (!previousRoutes.contains(semanticsNode.id)) { + lastAdded = semanticsNode; } } if (lastAdded == null && newRoutes.size() > 0) { @@ -759,14 +935,14 @@ void updateSemantics(ByteBuffer buffer, String[] strings) { createWindowChangeEvent(lastAdded); } previousRoutes.clear(); - for (SemanticsObject semanticsObject : newRoutes) { - previousRoutes.add(semanticsObject.id); + for (SemanticsNode semanticsNode : newRoutes) { + previousRoutes.add(semanticsNode.id); } - Iterator> it = semanticsObjects.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); it.remove(); @@ -777,7 +953,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); @@ -817,7 +993,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; } @@ -843,25 +1019,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 (accessibilityFocusedObject != null && accessibilityFocusedObject.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 - && (accessibilityFocusedObject == null || (accessibilityFocusedObject.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); @@ -942,41 +1118,59 @@ private void sendAccessibilityEvent(AccessibilityEvent event) { rootAccessibilityView.getParent().requestSendAccessibilityEvent(rootAccessibilityView, event); } - private void createWindowChangeEvent(SemanticsObject route) { - AccessibilityEvent e = - obtainAccessibilityEvent(route.id, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + private void createWindowChangeEvent(SemanticsNode route) { + AccessibilityEvent e = obtainAccessibilityEvent( + route.id, + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED + ); String routeName = route.getRouteName(); e.getText().add(routeName); sendAccessibilityEvent(e); } - private void willRemoveSemanticsObject(SemanticsObject object) { - assert semanticsObjects.containsKey(object.id); - assert semanticsObjects.get(object.id) == object; + private void willRemoveSemanticsObject(SemanticsNode object) { + assert flutterSemanticsTree.containsKey(object.id); + assert flutterSemanticsTree.get(object.id) == object; object.parent = null; - if (accessibilityFocusedObject == object) { - sendAccessibilityEvent(accessibilityFocusedObject.id, - AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); - accessibilityFocusedObject = null; + if (accessibilityFocusedSemanticsNode == object) { + sendAccessibilityEvent( + accessibilityFocusedSemanticsNode.id, + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED + ); + accessibilityFocusedSemanticsNode = null; } - if (inputFocusedObject == object) { - inputFocusedObject = null; + if (inputFocusedSemanticsNode == object) { + inputFocusedSemanticsNode = null; } if (hoveredObject == object) { hoveredObject = 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? void reset() { - semanticsObjects.clear(); - if (accessibilityFocusedObject != null) - sendAccessibilityEvent(accessibilityFocusedObject.id, - AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); - accessibilityFocusedObject = null; + 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); } + // TODO(mattcarroll): are these standard accessibility actions as defined by Flutter? If so, where are they defined in Dart? public enum Action { TAP(1 << 0), LONG_PRESS(1 << 1), @@ -1000,14 +1194,15 @@ public enum Action { MOVE_CURSOR_FORWARD_BY_WORD(1 << 19), MOVE_CURSOR_BACKWARD_BY_WORD(1 << 20); + public final int value; + Action(int value) { this.value = value; } - - public final int value; } - enum Flag { + // TODO(mattcarroll): are these flags defined by Flutter? If so, where are they defined in Dart? + private enum Flag { HAS_CHECKED_STATE(1 << 0), IS_CHECKED(1 << 1), IS_SELECTED(1 << 2), @@ -1028,11 +1223,11 @@ enum Flag { IS_TOGGLED(1 << 17), HAS_IMPLICIT_SCROLLING(1 << 18); + final int value; + Flag(int value) { this.value = value; } - - final int value; } private enum TextDirection { @@ -1051,57 +1246,95 @@ public static TextDirection fromInt(int value) { } } + /** + * 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; + // 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; - boolean isStandardAction() { - return overrideId != -1; - } + // The user presented value which is displayed in the local context menu. + private String label; + + // The text used in overridden standard actions. + private String hint; } - private static class SemanticsObject { + /** + * 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; - 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 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; @@ -1109,12 +1342,12 @@ private static 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; @@ -1123,12 +1356,16 @@ private static class SemanticsObject { private float[] globalTransform; private Rect globalRect; - SemanticsObject(@NonNull AccessibilityBridge accessibilityBridge) { + SemanticsNode(@NonNull AccessibilityBridge accessibilityBridge) { this.accessibilityBridge = accessibilityBridge; } - SemanticsObject getAncestor(Predicate tester) { - SemanticsObject nextAncestor = parent; + /** + * 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; @@ -1138,51 +1375,59 @@ SemanticsObject getAncestor(Predicate tester) { return null; } - boolean hasAction(Action action) { + /** + * Returns true if the given {@code action} is supported by this {@code SemanticsNode}. + */ + // TODO(mattcarroll): does this.actions include child actions, too? + 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; @@ -1246,7 +1491,7 @@ void updateWith(ByteBuffer buffer, String[] strings) { childrenInTraversalOrder.clear(); for (int i = 0; i < childCount; ++i) { - SemanticsObject child = accessibilityBridge.getOrCreateObject(buffer.getInt()); + SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt()); child.parent = this; childrenInTraversalOrder.add(child); } @@ -1257,7 +1502,7 @@ void updateWith(ByteBuffer buffer, String[] strings) { childrenInHitTestOrder.clear(); for (int i = 0; i < childCount; ++i) { - SemanticsObject child = accessibilityBridge.getOrCreateObject(buffer.getInt()); + SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt()); child.parent = this; childrenInHitTestOrder.add(child); } @@ -1272,7 +1517,7 @@ void updateWith(ByteBuffer buffer, String[] strings) { customAccessibilityActions.clear(); for (int i = 0; i < actionCount; i++) { - CustomAccessibilityAction action = accessibilityBridge.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) { @@ -1301,12 +1546,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; @@ -1314,13 +1559,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; } @@ -1331,7 +1576,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)) { @@ -1344,7 +1589,7 @@ boolean isFocusable() { || (hint != null && !hint.isEmpty()); } - void collectRoutes(List edges) { + private void collectRoutes(List edges) { if (hasFlag(Flag.SCOPES_ROUTE)) { edges.add(this); } @@ -1355,7 +1600,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)) { @@ -1374,7 +1619,7 @@ String getRouteName() { return null; } - void updateRecursively(float[] ancestorTransform, Set visitedObjects, + private void updateRecursively(float[] ancestorTransform, Set visitedObjects, boolean forceUpdate) { visitedObjects.add(this); From a5b5af57b6cef6f3547c772ba3506872d29e3361 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 7 Mar 2019 17:35:42 -0800 Subject: [PATCH 3/5] Round 3: Replaced more questions with comments, moved a lot of accessibility stuff from FlutterView to AccessibilityBridge. --- .../io/flutter/view/AccessibilityBridge.java | 419 ++++++++++++++---- .../android/io/flutter/view/FlutterView.java | 212 ++------- 2 files changed, 381 insertions(+), 250 deletions(-) diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 4eb9f5ed33c42..29c4b363f3e55 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -5,15 +5,22 @@ package io.flutter.view; 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.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; @@ -27,18 +34,24 @@ /** * Bridge between Android's OS accessibility system and Flutter's accessibility system. * - * An {@code AccessibilityBridge} requires a real Android {@link View}, called the - * {@link #rootAccessibilityView}, which contains a Flutter UI. The {@link #rootAccessibilityView} - * is required at the time of {@code AccessibilityBridge}'s instantiation and is held - * for the duration of {@code AccessibilityBridge}'s lifespan. {@code AccessibilityBridge} - * invokes various accessibility methods on the {@link #rootAccessibilityView}, e.g., - * {@link View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)}. The - * {@link #rootAccessibilityView} is expected to notify the {@code AccessibilityBridge} - * of relevant interactions: {@link #setAccessibilityEnabled(boolean)}, - * {@link #handleTouchExploration(float, float)}, {@link #handleTouchExplorationExit()}, - * and {@link #reset()}. + * An {@code AccessibilityBridge} requires: + *
    + *
  • A real Android {@link View}, called the {@link #rootAccessibilityView}, which contains a + * Flutter UI. The {@link #rootAccessibilityView} is required at the time of + * {@code AccessibilityBridge}'s instantiation and is held for the duration of + * {@code AccessibilityBridge}'s lifespan. {@code AccessibilityBridge} invokes various + * accessibility methods on the {@link #rootAccessibilityView}, e.g., + * {@link View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)}. The + * {@link #rootAccessibilityView} is expected to notify the {@code AccessibilityBridge} of + * relevant interactions: {@link #onAccessibilityHoverEvent(MotionEvent)}, {@link #reset()}, + * {@link #updateSemantics(ByteBuffer, String[])}, and {@link #updateCustomAccessibilityActions(ByteBuffer, String[])}
  • + *
  • A {@link FlutterJNI} instance, corresponding to the running Flutter app.
  • + *
  • An {@link AccessibilityChannel} that is connected to the running Flutter app.
  • + *
  • Android's {@link AccessibilityManager} to query and listen for accessibility settings.
  • + *
  • Android's {@link ContentResolver} to listen for changes to system animation settings.
  • + *
* - * The {@code AccessibilityBridge} tricks Android into treating Flutter widgets as if + * 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 @@ -49,7 +62,7 @@ * 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): why do we use both? + * TODO(mattcarroll): consider moving FlutterJNI calls over to AccessibilityChannel */ public class AccessibilityBridge extends AccessibilityNodeProvider { private static final String TAG = "AccessibilityBridge"; @@ -79,6 +92,17 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { @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 @@ -105,7 +129,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { // See the Flutter docs on SemanticsNode: // https://docs.flutter.io/flutter/semantics/SemanticsNode-class.html @NonNull - private final Map flutterSemanticsTree; + 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 @@ -130,18 +154,17 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { // See the Flutter documentation for the Semantics widget: // https://docs.flutter.io/flutter/widgets/Semantics-class.html @NonNull - private final Map customAccessibilityActions; - - // TODO(mattcarroll): what is the source of truth for this supposed to be? Is it - // whether or not the OS has accessibility enabled? Or do other - // factors influence this value? - private boolean accessibilityEnabled = false; + 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. // @@ -160,17 +183,24 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { @Nullable private SemanticsNode hoveredObject; - // TODO(mattcarroll): what is this? the term "route" sounds navigation oriented... - private int previousRouteId = ROOT_NODE_ID; - - // TODO(mattcarroll): what is this? the term "route" sounds navigation oriented... + // 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 previousRoutes; + private final List flutterNavigationStack = new ArrayList<>(); + + // TODO(mattcarroll): why do we need previouseRouteId if we have flutterNavigationStack + private int previousRouteId = ROOT_NODE_ID; // 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() { /** @@ -208,23 +238,153 @@ public void onTooltip(@NonNull String message) { } }; - AccessibilityBridge(@NonNull View rootAccessibilityView, @NonNull FlutterJNI flutterJNI, @NonNull AccessibilityChannel accessibilityChannel) { + // 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); + } + + if (onAccessibilityChangeListener != null) { + onAccessibilityChangeListener.onAccessibilityChanged( + accessibilityEnabled, + accessibilityManager.isTouchExplorationEnabled() + ); + } + } + }; + + // Listener that is notified when accessibility touch exploration is turned on/off. + 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(); + + if (onAccessibilityChangeListener != null) { + onAccessibilityChangeListener.onAccessibilityChanged( + accessibilityManager.isEnabled(), + isTouchExplorationEnabled + ); + } + } + }; + + // 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); + } + + @Override + public void onChange(boolean selfChange, Uri uri) { + // Retrieve the current value of TRANSITION_ANIMATION_SCALE from the OS. + String value = 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 View rootAccessibilityView, + @NonNull FlutterJNI flutterJNI, + @NonNull AccessibilityChannel accessibilityChannel, + @NonNull AccessibilityManager accessibilityManager, + @NonNull ContentResolver contentResolver + ) { this.rootAccessibilityView = rootAccessibilityView; this.flutterJNI = flutterJNI; this.accessibilityChannel = accessibilityChannel; + this.accessibilityManager = accessibilityManager; + this.contentResolver = contentResolver; + decorView = ((Activity) rootAccessibilityView.getContext()).getWindow().getDecorView(); - flutterSemanticsTree = new HashMap<>(); - customAccessibilityActions = new HashMap<>(); - previousRoutes = new ArrayList<>(); + + // 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); + } } - void setAccessibilityEnabled(boolean accessibilityEnabled) { - this.accessibilityEnabled = accessibilityEnabled; - if (accessibilityEnabled) { - this.accessibilityChannel.setAccessibilityMessageHandler(accessibilityMessageHandler); - } else { - this.accessibilityChannel.setAccessibilityMessageHandler(null); + /** + * 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 of the Android OS currently has accessibility enabled, false otherwise. + */ + public boolean isAccessibilityEnabled() { + return accessibilityManager.isEnabled(); + } + + /** + * Returns true of 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) { @@ -832,34 +992,78 @@ private CustomAccessibilityAction getOrCreateAccessibilityAction(int id) { 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) { + /** + * This method should be invoked when a new hover interaction begins with a {@code SemanticsNode}, + * or when an existing hover interaction sees a movement of the cursor. + * + * This method checks to see if the cursor has moved from one {@code SemanticsNode} to another. + * If it has, this method informs the Android accessibility system of the change by first sending + * a {@link AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} event for the new hover node, followed by + * a {@link AccessibilityEvent#TYPE_VIEW_HOVER_EXIT} event for the old hover node. + */ + private void handleTouchExploration(float x, float y) { if (flutterSemanticsTree.isEmpty()) { return; } - SemanticsNode newObject = getRootSemanticsNode().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; } } /** * Updates the Android cache of Flutter's currently registered custom accessibility actions. */ - // TODO(mattcarroll): how are custom actions deleted? this appears to only add them... + // 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()) { @@ -923,7 +1127,7 @@ void updateSemantics(@NonNull ByteBuffer buffer, @NonNull String[] strings) { // previously cached route id. SemanticsNode lastAdded = null; for (SemanticsNode semanticsNode : newRoutes) { - if (!previousRoutes.contains(semanticsNode.id)) { + if (!flutterNavigationStack.contains(semanticsNode.id)) { lastAdded = semanticsNode; } } @@ -932,11 +1136,11 @@ void updateSemantics(@NonNull ByteBuffer buffer, @NonNull String[] strings) { } if (lastAdded != null && lastAdded.id != previousRouteId) { previousRouteId = lastAdded.id; - createWindowChangeEvent(lastAdded); + createAndSendWindowChangeEvent(lastAdded); } - previousRoutes.clear(); + flutterNavigationStack.clear(); for (SemanticsNode semanticsNode : newRoutes) { - previousRoutes.add(semanticsNode.id); + flutterNavigationStack.add(semanticsNode.id); } Iterator> it = flutterSemanticsTree.entrySet().iterator(); @@ -944,7 +1148,7 @@ void updateSemantics(@NonNull ByteBuffer buffer, @NonNull String[] strings) { Map.Entry entry = it.next(); SemanticsNode object = entry.getValue(); if (!visitedObjects.contains(object)) { - willRemoveSemanticsObject(object); + willRemoveSemanticsNode(object); it.remove(); } } @@ -1092,57 +1296,95 @@ 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(rootAccessibilityView.getContext().getPackageName()); - event.setSource(rootAccessibilityView, 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) { + 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; } + // TODO(mattcarroll): why are we explicitly talking to the root view's parent? rootAccessibilityView.getParent().requestSendAccessibilityEvent(rootAccessibilityView, event); } - private void createWindowChangeEvent(SemanticsNode route) { - AccessibilityEvent e = obtainAccessibilityEvent( + /** + * 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(SemanticsNode object) { - assert flutterSemanticsTree.containsKey(object.id); - assert flutterSemanticsTree.get(object.id) == object; - object.parent = null; - if (accessibilityFocusedSemanticsNode == 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 == object) { + if (inputFocusedSemanticsNode == semanticsNodeToBeRemoved) { inputFocusedSemanticsNode = null; } - if (hoveredObject == object) { + if (hoveredObject == semanticsNodeToBeRemoved) { hoveredObject = null; } } @@ -1157,7 +1399,7 @@ private void willRemoveSemanticsObject(SemanticsNode object) { * */ // TODO(mattcarroll): under what conditions is this method expected to be invoked? - void reset() { + public void reset() { flutterSemanticsTree.clear(); if (accessibilityFocusedSemanticsNode != null) { sendAccessibilityEvent( @@ -1170,7 +1412,16 @@ void reset() { sendAccessibilityEvent(0, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); } - // TODO(mattcarroll): are these standard accessibility actions as defined by Flutter? If so, where are they defined in Dart? + /** + * 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), @@ -1201,7 +1452,8 @@ public enum Action { } } - // TODO(mattcarroll): are these flags defined by Flutter? If so, where are they defined in Dart? + // 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), @@ -1230,6 +1482,19 @@ private enum Flag { } } + // 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, @@ -1377,8 +1642,10 @@ private SemanticsNode getAncestor(Predicate tester) { /** * 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. */ - // TODO(mattcarroll): does this.actions include child actions, too? private boolean hasAction(@NonNull Action action) { return (actions & action.value) != 0; } diff --git a/shell/platform/android/io/flutter/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index 5b3cd701169df..41d6558b7d0c5 100644 --- a/shell/platform/android/io/flutter/view/FlutterView.java +++ b/shell/platform/android/io/flutter/view/FlutterView.java @@ -7,16 +7,13 @@ 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; -import android.provider.Settings; import android.text.format.DateFormat; import android.util.AttributeSet; import android.util.Log; @@ -51,7 +48,7 @@ * An Android view containing a Flutter app. */ public class FlutterView extends SurfaceView - implements BinaryMessenger, TextureRegistry, AccessibilityManager.AccessibilityStateChangeListener { + implements BinaryMessenger, TextureRegistry { /** * Interface for those objects that maintain and expose a reference to a * {@code FlutterView} (such as a full-screen Flutter activity). @@ -89,7 +86,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; @@ -103,14 +99,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); } @@ -131,7 +132,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); @@ -160,13 +160,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); @@ -234,7 +231,6 @@ public void onPause() { } public void onPostResume() { - updateAccessibilityFeatures(); for (ActivityLifecycleListener listener : mActivityLifecycleListeners) { listener.onPostResume(); } @@ -572,7 +568,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. @@ -741,6 +737,12 @@ private void preRun() { resetAccessibilityTree(); } + void resetAccessibilityTree() { + if (mAccessibilityNodeProvider != null) { + mAccessibilityNodeProvider.reset(); + } + } + private void postRun() { } @@ -839,190 +841,52 @@ public void onFirstFrame() { } } - // ACCESSIBILITY - - private boolean mAccessibilityEnabled = false; - private boolean mTouchExplorationEnabled = false; - private int mAccessibilityFeatureFlags = 0; - private TouchExplorationListener mTouchExplorationListener; - @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 = 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); - } - } - - 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, - getFlutterNativeView().getFlutterJNI(), - 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 From b5788849307b6e37f70799889df9e25a6b9fbdc6 Mon Sep 17 00:00:00 2001 From: Michael Klimushyn Date: Fri, 8 Mar 2019 15:54:24 -0800 Subject: [PATCH 4/5] Rebase lint.xml The new error is really an existing error, just moved to a new file. Not fixing it immediately because this lint is actually arguable. We may want to turn it off entirely in the future. --- tools/android_lint/baseline.xml | 62 ++++++++++++++++----------------- 1 file changed, 31 insertions(+), 31 deletions(-) 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"/> From f275cb9b4b4d1bb93e3f87735f7f47f7c037f785 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 8 Mar 2019 16:55:57 -0800 Subject: [PATCH 5/5] Fixed wrong method called from dispatchSemanticsAction in FlutterJNI, also corrected some comment typos. --- .../android/io/flutter/embedding/engine/FlutterJNI.java | 2 +- .../platform/android/io/flutter/view/AccessibilityBridge.java | 4 ++-- shell/platform/android/io/flutter/view/FlutterView.java | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java b/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java index f9bd7b47ed639..41e128ecd63f7 100644 --- a/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java +++ b/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java @@ -330,7 +330,7 @@ public void dispatchSemanticsAction(int id, @NonNull AccessibilityBridge.Action } public void dispatchSemanticsAction(int id, @NonNull AccessibilityBridge.Action action, @Nullable Object args) { - ensureNotAttachedToNative(); + ensureAttachedToNative(); ByteBuffer encodedArgs = null; int position = 0; diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index b0621083fae61..812170155ec75 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -366,14 +366,14 @@ public void release() { } /** - * Returns true of the Android OS currently has accessibility enabled, false otherwise. + * Returns true if the Android OS currently has accessibility enabled, false otherwise. */ public boolean isAccessibilityEnabled() { return accessibilityManager.isEnabled(); } /** - * Returns true of the Android OS currently has touch exploration enabled, false otherwise. + * Returns true if the Android OS currently has touch exploration enabled, false otherwise. */ public boolean isTouchExplorationEnabled() { return accessibilityManager.isTouchExplorationEnabled(); diff --git a/shell/platform/android/io/flutter/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index b489f58521fe0..18434c28924aa 100644 --- a/shell/platform/android/io/flutter/view/FlutterView.java +++ b/shell/platform/android/io/flutter/view/FlutterView.java @@ -50,8 +50,7 @@ /** * An Android view containing a Flutter app. */ -public class FlutterView extends SurfaceView - implements BinaryMessenger, TextureRegistry { +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).