| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package io.flutter.view; |
| |
| import android.annotation.SuppressLint; |
| import android.annotation.TargetApi; |
| import android.app.Activity; |
| import android.content.ContentResolver; |
| import android.content.Context; |
| import android.content.res.Configuration; |
| import android.database.ContentObserver; |
| import android.graphics.Rect; |
| import android.net.Uri; |
| import android.opengl.Matrix; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.provider.Settings; |
| import android.text.SpannableString; |
| import android.text.TextUtils; |
| import android.text.style.LocaleSpan; |
| import android.text.style.TtsSpan; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.WindowInsets; |
| import android.view.WindowManager; |
| import android.view.accessibility.AccessibilityEvent; |
| import android.view.accessibility.AccessibilityManager; |
| import android.view.accessibility.AccessibilityNodeInfo; |
| import android.view.accessibility.AccessibilityNodeProvider; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.RequiresApi; |
| import androidx.annotation.VisibleForTesting; |
| import io.flutter.BuildConfig; |
| import io.flutter.Log; |
| import io.flutter.embedding.engine.systemchannels.AccessibilityChannel; |
| import io.flutter.plugin.platform.PlatformViewsAccessibilityDelegate; |
| import io.flutter.util.Predicate; |
| import io.flutter.util.ViewUtils; |
| import java.nio.ByteBuffer; |
| import java.nio.ByteOrder; |
| import java.nio.charset.Charset; |
| import java.util.*; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| /** |
| * Bridge between Android's OS accessibility system and Flutter's accessibility system. |
| * |
| * <p>An {@code AccessibilityBridge} requires: |
| * |
| * <ul> |
| * <li>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[], ByteBuffer[])}, and {@link |
| * #updateCustomAccessibilityActions(ByteBuffer, String[])} |
| * <li>An {@link AccessibilityChannel} that is connected to the running Flutter app. |
| * <li>Android's {@link AccessibilityManager} to query and listen for accessibility settings. |
| * <li>Android's {@link ContentResolver} to listen for changes to system animation settings. |
| * </ul> |
| * |
| * The {@code AccessibilityBridge} causes Android to treat Flutter {@code SemanticsNode}s as if they |
| * were accessible Android {@link View}s. Accessibility requests may be sent from a Flutter widget |
| * to the Android OS, as if it were an Android {@link View}, and accessibility events may be |
| * consumed by a Flutter widget, as if it were an Android {@link View}. {@code AccessibilityBridge} |
| * refers to Flutter's accessible widgets as "virtual views" and identifies them with "virtual view |
| * IDs". |
| */ |
| 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 |
| // https://github.com/flutter/flutter/issues/11099 is resolved. |
| private static final int ACTION_SHOW_ON_SCREEN = 16908342; // API level 23 |
| |
| private static final float SCROLL_EXTENT_FOR_INFINITY = 100000.0f; |
| private static final float SCROLL_POSITION_CAP_FOR_INFINITY = 70000.0f; |
| private static final int ROOT_NODE_ID = 0; |
| private static final int SCROLLABLE_ACTIONS = |
| Action.SCROLL_RIGHT.value |
| | Action.SCROLL_LEFT.value |
| | Action.SCROLL_UP.value |
| | Action.SCROLL_DOWN.value; |
| // Flags that make a node accessibilty focusable. |
| private static final int FOCUSABLE_FLAGS = |
| Flag.HAS_CHECKED_STATE.value |
| | Flag.IS_CHECKED.value |
| | Flag.IS_SELECTED.value |
| | Flag.IS_TEXT_FIELD.value |
| | Flag.IS_FOCUSED.value |
| | Flag.HAS_ENABLED_STATE.value |
| | Flag.IS_ENABLED.value |
| | Flag.IS_IN_MUTUALLY_EXCLUSIVE_GROUP.value |
| | Flag.HAS_TOGGLED_STATE.value |
| | Flag.IS_TOGGLED.value |
| | Flag.IS_FOCUSABLE.value |
| | Flag.IS_SLIDER.value; |
| |
| // The minimal ID for an engine generated AccessibilityNodeInfo. |
| // |
| // The AccessibilityNodeInfo node IDs are generated by the framework for most Flutter semantic |
| // nodes. |
| // When embedding platform views, the framework does not have the accessibility information for |
| // the embedded view; |
| // in this case the engine generates AccessibilityNodeInfo that mirrors the a11y information |
| // exposed by the platform |
| // view. To avoid the need of synchronizing the framework and engine mechanisms for generating the |
| // next ID, we split |
| // the 32bit range of virtual node IDs into 2. The least significant 16 bits are used for |
| // framework generated IDs |
| // and the most significant 16 bits are used for engine generated IDs. |
| private static final int MIN_ENGINE_GENERATED_NODE_ID = 1 << 16; |
| |
| // Font weight adjustment for bold text. FontWeight.Bold - FontWeight.Normal = w700 - w400 = 300. |
| private static final int BOLD_TEXT_WEIGHT_ADJUSTMENT = 300; |
| |
| /// 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; |
| |
| // The accessibility communication API between Flutter's Android embedding and |
| // the Flutter framework. |
| @NonNull private final AccessibilityChannel accessibilityChannel; |
| |
| // Android's {@link AccessibilityManager}, which we can query to see if accessibility is |
| // turned on, as well as listen for changes to accessibility's activation. |
| @NonNull private final AccessibilityManager accessibilityManager; |
| |
| @NonNull private final AccessibilityViewEmbedder accessibilityViewEmbedder; |
| |
| // The delegate for interacting with embedded platform views. Used to embed accessibility data for |
| // an embedded view in the accessibility tree. |
| @NonNull private final PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate; |
| |
| // 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 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://api.flutter.dev/flutter/semantics/SemanticsNode-class.html |
| @NonNull private final Map<Integer, SemanticsNode> flutterSemanticsTree = new HashMap<>(); |
| |
| // The set of all custom Flutter accessibility actions that are present in the running |
| // Flutter app, stored as a Map from each action's ID to the definition of the custom |
| // accessibility |
| // action. |
| // |
| // Flutter and Android support a number of built-in accessibility actions. However, these |
| // predefined actions are not always sufficient for a desired interaction. Android facilitates |
| // custom accessibility actions, |
| // https://developer.android.com/reference/android/view/accessibility/AccessibilityNodeInfo.AccessibilityAction. |
| // Flutter supports custom accessibility actions via {@code customSemanticsActions} within |
| // a {@code Semantics} widget, https://api.flutter.dev/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://api.flutter.dev/flutter/widgets/Semantics-class.html |
| @NonNull |
| private final Map<Integer, CustomAccessibilityAction> customAccessibilityActions = |
| new HashMap<>(); |
| |
| // The {@code SemanticsNode} within Flutter that currently has the focus of Android's |
| // accessibility system. |
| // |
| // This is null when a node embedded by the AccessibilityViewEmbedder has the focus. |
| @Nullable private SemanticsNode accessibilityFocusedSemanticsNode; |
| |
| // The virtual ID of the currently embedded node with accessibility focus. |
| // |
| // This is the ID of a node generated by the AccessibilityViewEmbedder if an embedded node is |
| // focused, |
| // null otherwise. |
| private Integer embeddedAccessibilityFocusedNodeId; |
| |
| // The virtual ID of the currently embedded node with input focus. |
| // |
| // This is the ID of a node generated by the AccessibilityViewEmbedder if an embedded node is |
| // focused, |
| // null otherwise. |
| private Integer embeddedInputFocusedNodeId; |
| |
| // The accessibility features that should currently be active within Flutter, represented as |
| // a bitmask whose values comes from {@link AccessibilityFeature}. |
| private int accessibilityFeatureFlags = 0; |
| |
| // The {@code SemanticsNode} within Flutter that currently has the focus of Android's input |
| // system. |
| // |
| // Input focus is independent of accessibility focus. It is possible that accessibility focus |
| // and input focus target the same {@code SemanticsNode}, but it is also possible that one |
| // {@code SemanticsNode} has input focus while a different {@code SemanticsNode} has |
| // accessibility focus. For example, a user may use a D-Pad to navigate to a text field, giving |
| // it accessibility focus, and then enable input on that text field, giving it input focus. Then |
| // the user moves the accessibility focus to a nearby label to get info about the label, while |
| // maintaining input focus on the original text field. |
| @Nullable private SemanticsNode inputFocusedSemanticsNode; |
| |
| // Keeps track of the last semantics node that had the input focus. |
| // |
| // This is used to determine if the input focus has changed since the last time the |
| // {@code inputFocusSemanticsNode} has been set, so that we can send a {@code TYPE_VIEW_FOCUSED} |
| // event when it changes. |
| @Nullable private SemanticsNode lastInputFocusedSemanticsNode; |
| |
| // The widget within Flutter that currently sits beneath a cursor, e.g, |
| // beneath a stylus or mouse cursor. |
| @Nullable private SemanticsNode hoveredObject; |
| |
| @VisibleForTesting |
| public int getHoveredObjectId() { |
| return hoveredObject.id; |
| } |
| |
| // 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<Integer> flutterNavigationStack = new ArrayList<>(); |
| |
| // TODO(mattcarroll): why do we need previouseRouteId if we have flutterNavigationStack |
| private int previousRouteId = ROOT_NODE_ID; |
| |
| // Tracks the left system inset of the screen because Flutter needs to manually adjust |
| // accessibility positioning when in reverse-landscape. This is an Android bug that Flutter |
| // is solving for itself. |
| @NonNull private Integer lastLeftFrameInset = 0; |
| |
| @Nullable private OnAccessibilityChangeListener onAccessibilityChangeListener; |
| |
| // Whether the users are using assistive technologies to interact with the devices. |
| // |
| // The getter returns true when at least one of the assistive technologies is running: |
| // TalkBack, SwitchAccess, or VoiceAccess. |
| @VisibleForTesting |
| public boolean getAccessibleNavigation() { |
| return accessibleNavigation; |
| } |
| |
| private boolean accessibleNavigation = false; |
| |
| private void setAccessibleNavigation(boolean value) { |
| if (accessibleNavigation == value) { |
| return; |
| } |
| accessibleNavigation = value; |
| if (accessibleNavigation) { |
| accessibilityFeatureFlags |= AccessibilityFeature.ACCESSIBLE_NAVIGATION.value; |
| } else { |
| accessibilityFeatureFlags &= ~AccessibilityFeature.ACCESSIBLE_NAVIGATION.value; |
| } |
| sendLatestAccessibilityFlagsToFlutter(); |
| } |
| |
| // Set to true after {@code release} has been invoked. |
| private boolean isReleased = false; |
| |
| // 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) { |
| 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 framework has requested focus on the given {@code nodeId}. */ |
| @Override |
| public void onFocus(int nodeId) { |
| sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_FOCUSED); |
| } |
| |
| /** The user has opened a tooltip. */ |
| @Override |
| public void onTooltip(@NonNull String message) { |
| // Native Android tooltip is no longer announced when it pops up after API 28 and is |
| // handled by |
| // AccessibilityNodeInfo.setTooltipText instead. |
| // |
| // To reproduce native behavior, see |
| // https://developer.android.com/guide/topics/ui/tooltips. |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { |
| return; |
| } |
| AccessibilityEvent e = |
| obtainAccessibilityEvent(ROOT_NODE_ID, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); |
| e.getText().add(message); |
| sendAccessibilityEvent(e); |
| } |
| |
| /** New custom accessibility actions exist in Flutter. Update our Android-side cache. */ |
| @Override |
| public void updateCustomAccessibilityActions(ByteBuffer buffer, String[] strings) { |
| buffer.order(ByteOrder.LITTLE_ENDIAN); |
| AccessibilityBridge.this.updateCustomAccessibilityActions(buffer, strings); |
| } |
| |
| /** Flutter's semantics tree has changed. Update our Android-side cache. */ |
| @Override |
| public void updateSemantics( |
| ByteBuffer buffer, String[] strings, ByteBuffer[] stringAttributeArgs) { |
| buffer.order(ByteOrder.LITTLE_ENDIAN); |
| for (ByteBuffer args : stringAttributeArgs) { |
| args.order(ByteOrder.LITTLE_ENDIAN); |
| } |
| AccessibilityBridge.this.updateSemantics(buffer, strings, stringAttributeArgs); |
| } |
| }; |
| |
| // 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 (isReleased) { |
| return; |
| } |
| if (accessibilityEnabled) { |
| accessibilityChannel.setAccessibilityMessageHandler(accessibilityMessageHandler); |
| accessibilityChannel.onAndroidAccessibilityEnabled(); |
| } else { |
| setAccessibleNavigation(false); |
| accessibilityChannel.setAccessibilityMessageHandler(null); |
| accessibilityChannel.onAndroidAccessibilityDisabled(); |
| } |
| |
| if (onAccessibilityChangeListener != null) { |
| onAccessibilityChangeListener.onAccessibilityChanged( |
| accessibilityEnabled, accessibilityManager.isTouchExplorationEnabled()); |
| } |
| } |
| }; |
| |
| // Listener that is notified when accessibility touch exploration is turned on/off. |
| // This is guarded at instantiation time. |
| @TargetApi(19) |
| @RequiresApi(19) |
| private final AccessibilityManager.TouchExplorationStateChangeListener |
| touchExplorationStateChangeListener; |
| |
| // 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) { |
| if (isReleased) { |
| return; |
| } |
| // Retrieve the current value of TRANSITION_ANIMATION_SCALE from the OS. |
| String value = |
| Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1 |
| ? null |
| : Settings.Global.getString( |
| contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE); |
| |
| boolean shouldAnimationsBeDisabled = value != null && value.equals("0"); |
| if (shouldAnimationsBeDisabled) { |
| accessibilityFeatureFlags |= AccessibilityFeature.DISABLE_ANIMATIONS.value; |
| } else { |
| accessibilityFeatureFlags &= ~AccessibilityFeature.DISABLE_ANIMATIONS.value; |
| } |
| sendLatestAccessibilityFlagsToFlutter(); |
| } |
| }; |
| |
| public AccessibilityBridge( |
| @NonNull View rootAccessibilityView, |
| @NonNull AccessibilityChannel accessibilityChannel, |
| @NonNull AccessibilityManager accessibilityManager, |
| @NonNull ContentResolver contentResolver, |
| @NonNull PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate) { |
| this( |
| rootAccessibilityView, |
| accessibilityChannel, |
| accessibilityManager, |
| contentResolver, |
| new AccessibilityViewEmbedder(rootAccessibilityView, MIN_ENGINE_GENERATED_NODE_ID), |
| platformViewsAccessibilityDelegate); |
| } |
| |
| @VisibleForTesting |
| public AccessibilityBridge( |
| @NonNull View rootAccessibilityView, |
| @NonNull AccessibilityChannel accessibilityChannel, |
| @NonNull AccessibilityManager accessibilityManager, |
| @NonNull ContentResolver contentResolver, |
| @NonNull AccessibilityViewEmbedder accessibilityViewEmbedder, |
| @NonNull PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate) { |
| this.rootAccessibilityView = rootAccessibilityView; |
| this.accessibilityChannel = accessibilityChannel; |
| this.accessibilityManager = accessibilityManager; |
| this.contentResolver = contentResolver; |
| this.accessibilityViewEmbedder = accessibilityViewEmbedder; |
| this.platformViewsAccessibilityDelegate = platformViewsAccessibilityDelegate; |
| // 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 = |
| new AccessibilityManager.TouchExplorationStateChangeListener() { |
| @Override |
| public void onTouchExplorationStateChanged(boolean isTouchExplorationEnabled) { |
| if (isReleased) { |
| return; |
| } |
| if (!isTouchExplorationEnabled) { |
| setAccessibleNavigation(false); |
| onTouchExplorationExit(); |
| } |
| |
| if (onAccessibilityChangeListener != null) { |
| onAccessibilityChangeListener.onAccessibilityChanged( |
| accessibilityManager.isEnabled(), isTouchExplorationEnabled); |
| } |
| } |
| }; |
| touchExplorationStateChangeListener.onTouchExplorationStateChanged( |
| accessibilityManager.isTouchExplorationEnabled()); |
| this.accessibilityManager.addTouchExplorationStateChangeListener( |
| touchExplorationStateChangeListener); |
| } else { |
| touchExplorationStateChangeListener = null; |
| } |
| |
| // 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); |
| } |
| |
| // Tells Flutter whether the text should be bolded or not. If the user changes bold text |
| // setting, the configuration will change and trigger a re-build of the accesibiltyBridge. |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
| setBoldTextFlag(); |
| } |
| |
| platformViewsAccessibilityDelegate.attachAccessibilityBridge(this); |
| } |
| |
| /** |
| * Disconnects any listeners and/or delegates that were initialized in {@code |
| * AccessibilityBridge}'s constructor, or added after. |
| * |
| * <p>Do not use this instance after invoking {@code release}. The behavior of any method invoked |
| * on this {@code AccessibilityBridge} after invoking {@code release()} is undefined. |
| */ |
| public void release() { |
| isReleased = true; |
| platformViewsAccessibilityDelegate.detachAccessibilityBridge(); |
| setOnAccessibilityChangeListener(null); |
| accessibilityManager.removeAccessibilityStateChangeListener(accessibilityStateChangeListener); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { |
| accessibilityManager.removeTouchExplorationStateChangeListener( |
| touchExplorationStateChangeListener); |
| } |
| contentResolver.unregisterContentObserver(animationScaleObserver); |
| accessibilityChannel.setAccessibilityMessageHandler(null); |
| } |
| |
| /** Returns true if the Android OS currently has accessibility enabled, false otherwise. */ |
| public boolean isAccessibilityEnabled() { |
| return accessibilityManager.isEnabled(); |
| } |
| |
| /** Returns true if the Android OS currently has touch exploration enabled, false otherwise. */ |
| public boolean isTouchExplorationEnabled() { |
| return accessibilityManager.isTouchExplorationEnabled(); |
| } |
| |
| /** |
| * Sets a listener on this {@code AccessibilityBridge}, which is notified whenever accessibility |
| * activation, or touch exploration activation changes. |
| */ |
| public void setOnAccessibilityChangeListener(@Nullable OnAccessibilityChangeListener listener) { |
| this.onAccessibilityChangeListener = listener; |
| } |
| |
| /** Sends the current value of {@link #accessibilityFeatureFlags} to Flutter. */ |
| private void sendLatestAccessibilityFlagsToFlutter() { |
| accessibilityChannel.setAccessibilityFeatures(accessibilityFeatureFlags); |
| } |
| |
| private boolean shouldSetCollectionInfo(final SemanticsNode semanticsNode) { |
| // TalkBack expects a number of rows and/or columns greater than 0 to announce |
| // in list and out of list. For an infinite or growing list, you have to |
| // specify something > 0 to get "in list" announcements. |
| // TalkBack will also only track one list at a time, so we only want to set this |
| // for a list that contains the current a11y focused semanticsNode - otherwise, if there |
| // are two lists or nested lists, we may end up with announcements for only the last |
| // one that is currently available in the semantics tree. However, we also want |
| // to set it if we're exiting a list to a non-list, so that we can get the "out of list" |
| // announcement when A11y focus moves out of a list and not into another list. |
| return semanticsNode.scrollChildren > 0 |
| && (SemanticsNode.nullableHasAncestor( |
| accessibilityFocusedSemanticsNode, o -> o == semanticsNode) |
| || !SemanticsNode.nullableHasAncestor( |
| accessibilityFocusedSemanticsNode, o -> o.hasFlag(Flag.HAS_IMPLICIT_SCROLLING))); |
| } |
| |
| @TargetApi(31) |
| @RequiresApi(31) |
| private void setBoldTextFlag() { |
| if (rootAccessibilityView == null || rootAccessibilityView.getResources() == null) { |
| return; |
| } |
| int fontWeightAdjustment = |
| rootAccessibilityView.getResources().getConfiguration().fontWeightAdjustment; |
| boolean shouldBold = |
| fontWeightAdjustment != Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED |
| && fontWeightAdjustment >= BOLD_TEXT_WEIGHT_ADJUSTMENT; |
| |
| if (shouldBold) { |
| accessibilityFeatureFlags |= AccessibilityFeature.BOLD_TEXT.value; |
| } else { |
| accessibilityFeatureFlags &= AccessibilityFeature.BOLD_TEXT.value; |
| } |
| sendLatestAccessibilityFlagsToFlutter(); |
| } |
| |
| @VisibleForTesting |
| public AccessibilityNodeInfo obtainAccessibilityNodeInfo(View rootView) { |
| return AccessibilityNodeInfo.obtain(rootView); |
| } |
| |
| @VisibleForTesting |
| public AccessibilityNodeInfo obtainAccessibilityNodeInfo(View rootView, int virtualViewId) { |
| return AccessibilityNodeInfo.obtain(rootView, virtualViewId); |
| } |
| |
| /** |
| * Returns {@link AccessibilityNodeInfo} for the view corresponding to the given {@code |
| * virtualViewId}. |
| * |
| * <p>This method is invoked by Android's accessibility system when Android needs accessibility |
| * info for a given view. |
| * |
| * <p>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. |
| * |
| * <p>Depending on the type of Flutter {@code SemanticsNode} that is requested, the returned |
| * {@link AccessibilityNodeInfo} pretends that the {@code SemanticsNode} in question comes from a |
| * specialize Android view, e.g., {@link Flag#IS_TEXT_FIELD} maps to {@code |
| * android.widget.EditText}, {@link Flag#IS_BUTTON} maps to {@code android.widget.Button}, and |
| * {@link Flag#IS_IMAGE} maps to {@code android.widget.ImageView}. In the case that no specialized |
| * view applies, the returned {@link AccessibilityNodeInfo} pretends that it represents a {@code |
| * android.view.View}. |
| */ |
| @Override |
| @SuppressWarnings("deprecation") |
| // Suppressing Lint warning for new API, as we are version guarding all calls to newer APIs |
| @SuppressLint("NewApi") |
| public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { |
| setAccessibleNavigation(true); |
| if (virtualViewId >= MIN_ENGINE_GENERATED_NODE_ID) { |
| // The node is in the engine generated range, and is provided by the accessibility view |
| // embedder. |
| return accessibilityViewEmbedder.createAccessibilityNodeInfo(virtualViewId); |
| } |
| |
| if (virtualViewId == View.NO_ID) { |
| AccessibilityNodeInfo result = obtainAccessibilityNodeInfo(rootAccessibilityView); |
| rootAccessibilityView.onInitializeAccessibilityNodeInfo(result); |
| // TODO(mattcarroll): what does it mean for the semantics tree to contain or not contain |
| // the root node ID? |
| if (flutterSemanticsTree.containsKey(ROOT_NODE_ID)) { |
| result.addChild(rootAccessibilityView, ROOT_NODE_ID); |
| } |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { |
| result.setImportantForAccessibility(false); |
| } |
| return result; |
| } |
| |
| SemanticsNode semanticsNode = flutterSemanticsTree.get(virtualViewId); |
| if (semanticsNode == null) { |
| return null; |
| } |
| |
| // Generate accessibility node for platform views using a virtual display. |
| // |
| // In this case, register the accessibility node in the view embedder, |
| // so the accessibility tree can be mirrored as a subtree of the Flutter accessibility tree. |
| // This is in constrast to hybrid composition where the embedded view is in the view hiearchy, |
| // so it doesn't need to be mirrored. |
| // |
| // See the case down below for how hybrid composition is handled. |
| if (semanticsNode.platformViewId != -1) { |
| if (platformViewsAccessibilityDelegate.usesVirtualDisplay(semanticsNode.platformViewId)) { |
| View embeddedView = |
| platformViewsAccessibilityDelegate.getPlatformViewById(semanticsNode.platformViewId); |
| if (embeddedView == null) { |
| return null; |
| } |
| Rect bounds = semanticsNode.getGlobalRect(); |
| return accessibilityViewEmbedder.getRootNode(embeddedView, semanticsNode.id, bounds); |
| } |
| } |
| |
| AccessibilityNodeInfo result = |
| obtainAccessibilityNodeInfo(rootAccessibilityView, virtualViewId); |
| |
| // Accessibility Scanner uses isImportantForAccessibility to decide whether to check |
| // or skip this node. |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { |
| result.setImportantForAccessibility(isImportant(semanticsNode)); |
| } |
| |
| // Work around for https://github.com/flutter/flutter/issues/21030 |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| result.setViewIdResourceName(""); |
| if (semanticsNode.identifier != null) { |
| result.setViewIdResourceName(semanticsNode.identifier); |
| } |
| } |
| result.setPackageName(rootAccessibilityView.getContext().getPackageName()); |
| result.setClassName("android.view.View"); |
| result.setSource(rootAccessibilityView, virtualViewId); |
| result.setFocusable(semanticsNode.isFocusable()); |
| if (inputFocusedSemanticsNode != null) { |
| result.setFocused(inputFocusedSemanticsNode.id == virtualViewId); |
| } |
| |
| if (accessibilityFocusedSemanticsNode != null) { |
| result.setAccessibilityFocused(accessibilityFocusedSemanticsNode.id == virtualViewId); |
| } |
| |
| if (semanticsNode.hasFlag(Flag.IS_TEXT_FIELD)) { |
| result.setPassword(semanticsNode.hasFlag(Flag.IS_OBSCURED)); |
| if (!semanticsNode.hasFlag(Flag.IS_READ_ONLY)) { |
| result.setClassName("android.widget.EditText"); |
| } |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| result.setEditable(!semanticsNode.hasFlag(Flag.IS_READ_ONLY)); |
| if (semanticsNode.textSelectionBase != -1 && semanticsNode.textSelectionExtent != -1) { |
| result.setTextSelection( |
| semanticsNode.textSelectionBase, semanticsNode.textSelectionExtent); |
| } |
| // Text fields will always be created as a live region when they have input focus, |
| // so that updates to the label trigger polite announcements. This makes it easy to |
| // follow a11y guidelines for text fields on Android. |
| if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2 |
| && accessibilityFocusedSemanticsNode != null |
| && accessibilityFocusedSemanticsNode.id == virtualViewId) { |
| result.setLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); |
| } |
| } |
| |
| // Cursor movements |
| int granularities = 0; |
| if (semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); |
| granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER; |
| } |
| if (semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); |
| granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER; |
| } |
| if (semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_WORD)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); |
| granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD; |
| } |
| if (semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_WORD)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); |
| granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD; |
| } |
| result.setMovementGranularities(granularities); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP |
| && semanticsNode.maxValueLength >= 0) { |
| // Account for the fact that Flutter is counting Unicode scalar values and Android |
| // is counting UTF16 words. |
| final int length = semanticsNode.value == null ? 0 : semanticsNode.value.length(); |
| int a = length - semanticsNode.currentValueLength + semanticsNode.maxValueLength; |
| result.setMaxTextLength( |
| length - semanticsNode.currentValueLength + semanticsNode.maxValueLength); |
| } |
| } |
| |
| // These are non-ops on older devices. Attempting to interact with the text will cause Talkback |
| // to read the contents of the text box instead. |
| if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| if (semanticsNode.hasAction(Action.SET_SELECTION)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION); |
| } |
| if (semanticsNode.hasAction(Action.COPY)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_COPY); |
| } |
| if (semanticsNode.hasAction(Action.CUT)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_CUT); |
| } |
| if (semanticsNode.hasAction(Action.PASTE)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_PASTE); |
| } |
| } |
| |
| // Set text API isn't available until API 21. |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { |
| if (semanticsNode.hasAction(Action.SET_TEXT)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SET_TEXT); |
| } |
| } |
| |
| if (semanticsNode.hasFlag(Flag.IS_BUTTON) || semanticsNode.hasFlag(Flag.IS_LINK)) { |
| result.setClassName("android.widget.Button"); |
| } |
| if (semanticsNode.hasFlag(Flag.IS_IMAGE)) { |
| result.setClassName("android.widget.ImageView"); |
| // TODO(jonahwilliams): Figure out a way conform to the expected id from TalkBack's |
| // CustomLabelManager. talkback/src/main/java/labeling/CustomLabelManager.java#L525 |
| } |
| if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2 |
| && semanticsNode.hasAction(Action.DISMISS)) { |
| result.setDismissable(true); |
| result.addAction(AccessibilityNodeInfo.ACTION_DISMISS); |
| } |
| |
| if (semanticsNode.parent != null) { |
| if (BuildConfig.DEBUG && semanticsNode.id <= ROOT_NODE_ID) { |
| Log.e(TAG, "Semantics node id is not > ROOT_NODE_ID."); |
| } |
| result.setParent(rootAccessibilityView, semanticsNode.parent.id); |
| } else { |
| if (BuildConfig.DEBUG && semanticsNode.id != ROOT_NODE_ID) { |
| Log.e(TAG, "Semantics node id does not equal ROOT_NODE_ID."); |
| } |
| result.setParent(rootAccessibilityView); |
| } |
| |
| if (semanticsNode.previousNodeId != -1 |
| && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { |
| result.setTraversalAfter(rootAccessibilityView, semanticsNode.previousNodeId); |
| } |
| |
| Rect bounds = semanticsNode.getGlobalRect(); |
| if (semanticsNode.parent != null) { |
| Rect parentBounds = semanticsNode.parent.getGlobalRect(); |
| Rect boundsInParent = new Rect(bounds); |
| boundsInParent.offset(-parentBounds.left, -parentBounds.top); |
| result.setBoundsInParent(boundsInParent); |
| } else { |
| result.setBoundsInParent(bounds); |
| } |
| final Rect boundsInScreen = getBoundsInScreen(bounds); |
| result.setBoundsInScreen(boundsInScreen); |
| result.setVisibleToUser(true); |
| result.setEnabled( |
| !semanticsNode.hasFlag(Flag.HAS_ENABLED_STATE) || semanticsNode.hasFlag(Flag.IS_ENABLED)); |
| |
| if (semanticsNode.hasAction(Action.TAP)) { |
| if (Build.VERSION.SDK_INT >= 21 && semanticsNode.onTapOverride != null) { |
| result.addAction( |
| new AccessibilityNodeInfo.AccessibilityAction( |
| AccessibilityNodeInfo.ACTION_CLICK, semanticsNode.onTapOverride.hint)); |
| result.setClickable(true); |
| } else { |
| result.addAction(AccessibilityNodeInfo.ACTION_CLICK); |
| result.setClickable(true); |
| } |
| } |
| if (semanticsNode.hasAction(Action.LONG_PRESS)) { |
| if (Build.VERSION.SDK_INT >= 21 && semanticsNode.onLongPressOverride != null) { |
| result.addAction( |
| new AccessibilityNodeInfo.AccessibilityAction( |
| AccessibilityNodeInfo.ACTION_LONG_CLICK, semanticsNode.onLongPressOverride.hint)); |
| result.setLongClickable(true); |
| } else { |
| result.addAction(AccessibilityNodeInfo.ACTION_LONG_CLICK); |
| result.setLongClickable(true); |
| } |
| } |
| if (semanticsNode.hasAction(Action.SCROLL_LEFT) |
| || semanticsNode.hasAction(Action.SCROLL_UP) |
| || semanticsNode.hasAction(Action.SCROLL_RIGHT) |
| || semanticsNode.hasAction(Action.SCROLL_DOWN)) { |
| result.setScrollable(true); |
| |
| // This tells Android's a11y to send scroll events when reaching the end of |
| // the visible viewport of a scrollable, unless the node itself does not |
| // allow implicit scrolling - then we leave the className as view.View. |
| // |
| // We should prefer setCollectionInfo to the class names, as this way we get "In List" |
| // and "Out of list" announcements. But we don't always know the counts, so we |
| // can fallback to the generic scroll view class names. |
| // |
| // On older APIs, we always fall back to the generic scroll view class names here. |
| // |
| // TODO(dnfield): We should add semantics properties for rows and columns in 2 dimensional |
| // lists, e.g. |
| // GridView. Right now, we're only supporting ListViews and only if they have scroll |
| // children. |
| if (semanticsNode.hasFlag(Flag.HAS_IMPLICIT_SCROLLING)) { |
| if (semanticsNode.hasAction(Action.SCROLL_LEFT) |
| || semanticsNode.hasAction(Action.SCROLL_RIGHT)) { |
| if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT |
| && shouldSetCollectionInfo(semanticsNode)) { |
| result.setCollectionInfo( |
| AccessibilityNodeInfo.CollectionInfo.obtain( |
| 0, // rows |
| semanticsNode.scrollChildren, // columns |
| false // hierarchical |
| )); |
| } else { |
| result.setClassName("android.widget.HorizontalScrollView"); |
| } |
| } else { |
| if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2 |
| && shouldSetCollectionInfo(semanticsNode)) { |
| result.setCollectionInfo( |
| AccessibilityNodeInfo.CollectionInfo.obtain( |
| semanticsNode.scrollChildren, // rows |
| 0, // columns |
| false // hierarchical |
| )); |
| } else { |
| result.setClassName("android.widget.ScrollView"); |
| } |
| } |
| } |
| // TODO(ianh): Once we're on SDK v23+, call addAction to |
| // expose AccessibilityAction.ACTION_SCROLL_LEFT, _RIGHT, |
| // _UP, and _DOWN when appropriate. |
| if (semanticsNode.hasAction(Action.SCROLL_LEFT) |
| || semanticsNode.hasAction(Action.SCROLL_UP)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); |
| } |
| if (semanticsNode.hasAction(Action.SCROLL_RIGHT) |
| || semanticsNode.hasAction(Action.SCROLL_DOWN)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); |
| } |
| } |
| if (semanticsNode.hasAction(Action.INCREASE) || semanticsNode.hasAction(Action.DECREASE)) { |
| // TODO(jonahwilliams): support AccessibilityAction.ACTION_SET_PROGRESS once SDK is |
| // updated. |
| result.setClassName("android.widget.SeekBar"); |
| if (semanticsNode.hasAction(Action.INCREASE)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); |
| } |
| if (semanticsNode.hasAction(Action.DECREASE)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); |
| } |
| } |
| if (semanticsNode.hasFlag(Flag.IS_LIVE_REGION) |
| && Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| result.setLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); |
| } |
| |
| // Scopes routes are not focusable, only need to set the content |
| // for non-scopes-routes semantics nodes. |
| if (semanticsNode.hasFlag(Flag.IS_TEXT_FIELD)) { |
| result.setText(semanticsNode.getValue()); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { |
| result.setHintText(semanticsNode.getTextFieldHint()); |
| } |
| } else if (!semanticsNode.hasFlag(Flag.SCOPES_ROUTE)) { |
| CharSequence content = semanticsNode.getValueLabelHint(); |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { |
| if (semanticsNode.tooltip != null) { |
| // For backward compatibility with Flutter SDK before Android API |
| // level 28, the tooltip is appended at the end of content description. |
| content = content != null ? content : ""; |
| content = content + "\n" + semanticsNode.tooltip; |
| } |
| } |
| if (content != null) { |
| result.setContentDescription(content); |
| } |
| } |
| |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { |
| if (semanticsNode.tooltip != null) { |
| result.setTooltipText(semanticsNode.tooltip); |
| } |
| } |
| |
| boolean hasCheckedState = semanticsNode.hasFlag(Flag.HAS_CHECKED_STATE); |
| boolean hasToggledState = semanticsNode.hasFlag(Flag.HAS_TOGGLED_STATE); |
| if (BuildConfig.DEBUG && (hasCheckedState && hasToggledState)) { |
| Log.e(TAG, "Expected semanticsNode to have checked state and toggled state."); |
| } |
| result.setCheckable(hasCheckedState || hasToggledState); |
| if (hasCheckedState) { |
| result.setChecked(semanticsNode.hasFlag(Flag.IS_CHECKED)); |
| if (semanticsNode.hasFlag(Flag.IS_IN_MUTUALLY_EXCLUSIVE_GROUP)) { |
| result.setClassName("android.widget.RadioButton"); |
| } else { |
| result.setClassName("android.widget.CheckBox"); |
| } |
| } else if (hasToggledState) { |
| result.setChecked(semanticsNode.hasFlag(Flag.IS_TOGGLED)); |
| result.setClassName("android.widget.Switch"); |
| } |
| result.setSelected(semanticsNode.hasFlag(Flag.IS_SELECTED)); |
| |
| // Heading support |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { |
| result.setHeading(semanticsNode.hasFlag(Flag.IS_HEADER)); |
| } |
| |
| // Accessibility Focus |
| if (accessibilityFocusedSemanticsNode != null |
| && accessibilityFocusedSemanticsNode.id == virtualViewId) { |
| result.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); |
| } else { |
| result.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); |
| } |
| |
| // Actions on the local context menu |
| if (Build.VERSION.SDK_INT >= 21) { |
| if (semanticsNode.customAccessibilityActions != null) { |
| for (CustomAccessibilityAction action : semanticsNode.customAccessibilityActions) { |
| result.addAction( |
| new AccessibilityNodeInfo.AccessibilityAction(action.resourceId, action.label)); |
| } |
| } |
| } |
| |
| for (SemanticsNode child : semanticsNode.childrenInTraversalOrder) { |
| if (child.hasFlag(Flag.IS_HIDDEN)) { |
| continue; |
| } |
| if (child.platformViewId != -1) { |
| View embeddedView = |
| platformViewsAccessibilityDelegate.getPlatformViewById(child.platformViewId); |
| |
| // Add the embedded view as a child of the current accessibility node if it's not |
| // using a virtual display. |
| // |
| // In this case, the view is in the Activity's view hierarchy, so it doesn't need to be |
| // mirrored. |
| // |
| // See the case above for how virtual displays are handled. |
| if (!platformViewsAccessibilityDelegate.usesVirtualDisplay(child.platformViewId)) { |
| result.addChild(embeddedView); |
| continue; |
| } |
| } |
| result.addChild(rootAccessibilityView, child.id); |
| } |
| return result; |
| } |
| |
| private boolean isImportant(SemanticsNode node) { |
| if (node.hasFlag(Flag.SCOPES_ROUTE)) { |
| return false; |
| } |
| |
| if (node.getValueLabelHint() != null) { |
| return true; |
| } |
| |
| // Return true if the node has had any user action (not including system actions) |
| return (node.actions & ~systemAction) != 0; |
| } |
| |
| /** |
| * Get the bounds in screen with root FlutterView's offset. |
| * |
| * @param bounds the bounds in FlutterView |
| * @return the bounds with offset |
| */ |
| private Rect getBoundsInScreen(Rect bounds) { |
| Rect boundsInScreen = new Rect(bounds); |
| int[] locationOnScreen = new int[2]; |
| rootAccessibilityView.getLocationOnScreen(locationOnScreen); |
| boundsInScreen.offset(locationOnScreen[0], locationOnScreen[1]); |
| return boundsInScreen; |
| } |
| |
| /** |
| * Instructs the view represented by {@code virtualViewId} to carry out the desired {@code |
| * accessibilityAction}, perhaps configured by additional {@code arguments}. |
| * |
| * <p>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. |
| * |
| * <p>In a traditional Android app, the given view ID refers to a {@link View} within an Android |
| * {@link View} hierarchy. Flutter does not have an Android {@link View} hierarchy, therefore the |
| * given view ID is a {@code virtualViewId} that refers to a {@code SemanticsNode} within a |
| * Flutter app. The given arguments of this method are forwarded from Android to Flutter. |
| */ |
| @Override |
| public boolean performAction( |
| int virtualViewId, int accessibilityAction, @Nullable Bundle arguments) { |
| if (virtualViewId >= MIN_ENGINE_GENERATED_NODE_ID) { |
| // The node is in the engine generated range, and is handled by the accessibility view |
| // embedder. |
| boolean didPerform = |
| accessibilityViewEmbedder.performAction(virtualViewId, accessibilityAction, arguments); |
| if (didPerform |
| && accessibilityAction == AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS) { |
| embeddedAccessibilityFocusedNodeId = null; |
| } |
| return didPerform; |
| } |
| SemanticsNode semanticsNode = flutterSemanticsTree.get(virtualViewId); |
| if (semanticsNode == null) { |
| return false; |
| } |
| switch (accessibilityAction) { |
| case AccessibilityNodeInfo.ACTION_CLICK: |
| { |
| // Note: TalkBack prior to Oreo doesn't use this handler and instead simulates a |
| // click event at the center of the SemanticsNode. Other a11y services might go |
| // through this handler though. |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.TAP); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_LONG_CLICK: |
| { |
| // Note: TalkBack doesn't use this handler and instead simulates a long click event |
| // at the center of the SemanticsNode. Other a11y services might go through this |
| // handler though. |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.LONG_PRESS); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: |
| { |
| if (semanticsNode.hasAction(Action.SCROLL_UP)) { |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SCROLL_UP); |
| } else if (semanticsNode.hasAction(Action.SCROLL_LEFT)) { |
| // TODO(ianh): bidi support using textDirection |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SCROLL_LEFT); |
| } else if (semanticsNode.hasAction(Action.INCREASE)) { |
| semanticsNode.value = semanticsNode.increasedValue; |
| semanticsNode.valueAttributes = semanticsNode.increasedValueAttributes; |
| // Event causes Android to read out the updated value. |
| sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.INCREASE); |
| } else { |
| return false; |
| } |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: |
| { |
| if (semanticsNode.hasAction(Action.SCROLL_DOWN)) { |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SCROLL_DOWN); |
| } else if (semanticsNode.hasAction(Action.SCROLL_RIGHT)) { |
| // TODO(ianh): bidi support using textDirection |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SCROLL_RIGHT); |
| } else if (semanticsNode.hasAction(Action.DECREASE)) { |
| semanticsNode.value = semanticsNode.decreasedValue; |
| semanticsNode.valueAttributes = semanticsNode.decreasedValueAttributes; |
| // Event causes Android to read out the updated value. |
| sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.DECREASE); |
| } else { |
| return false; |
| } |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: |
| { |
| // Text selection APIs aren't available until API 18. We can't handle the case here so |
| // return false |
| // instead. It's extremely unlikely that this case would ever be triggered in the first |
| // place in API < |
| // 18. |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| return false; |
| } |
| return performCursorMoveAction(semanticsNode, virtualViewId, arguments, false); |
| } |
| case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: |
| { |
| // Text selection APIs aren't available until API 18. We can't handle the case here so |
| // return false |
| // instead. It's extremely unlikely that this case would ever be triggered in the first |
| // place in API < |
| // 18. |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| return false; |
| } |
| return performCursorMoveAction(semanticsNode, virtualViewId, arguments, true); |
| } |
| case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS: |
| { |
| // Focused semantics node must be reset before sending the |
| // TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED event. Otherwise, |
| // TalkBack may think the node is still focused. |
| if (accessibilityFocusedSemanticsNode != null |
| && accessibilityFocusedSemanticsNode.id == virtualViewId) { |
| accessibilityFocusedSemanticsNode = null; |
| } |
| if (embeddedAccessibilityFocusedNodeId != null |
| && embeddedAccessibilityFocusedNodeId == virtualViewId) { |
| embeddedAccessibilityFocusedNodeId = null; |
| } |
| accessibilityChannel.dispatchSemanticsAction( |
| virtualViewId, Action.DID_LOSE_ACCESSIBILITY_FOCUS); |
| sendAccessibilityEvent( |
| virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: |
| { |
| 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(); |
| } |
| // Focused semantics node must be set before sending the TYPE_VIEW_ACCESSIBILITY_FOCUSED |
| // event. Otherwise, TalkBack may think the node is not focused yet. |
| accessibilityFocusedSemanticsNode = semanticsNode; |
| |
| accessibilityChannel.dispatchSemanticsAction( |
| virtualViewId, Action.DID_GAIN_ACCESSIBILITY_FOCUS); |
| |
| HashMap<String, Object> message = new HashMap<>(); |
| message.put("type", "didGainFocus"); |
| message.put("nodeId", semanticsNode.id); |
| accessibilityChannel.channel.send(message); |
| |
| sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); |
| |
| if (semanticsNode.hasAction(Action.INCREASE) |
| || semanticsNode.hasAction(Action.DECREASE)) { |
| // SeekBars only announce themselves after this event. |
| sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); |
| } |
| |
| return true; |
| } |
| case ACTION_SHOW_ON_SCREEN: |
| { |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SHOW_ON_SCREEN); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_SET_SELECTION: |
| { |
| // Text selection APIs aren't available until API 18. We can't handle the case here so |
| // return false |
| // instead. It's extremely unlikely that this case would ever be triggered in the first |
| // place in API < |
| // 18. |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| return false; |
| } |
| final Map<String, Integer> selection = new HashMap<>(); |
| final boolean hasSelection = |
| arguments != null |
| && 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)); |
| } else { |
| // Clear the selection |
| selection.put("base", semanticsNode.textSelectionExtent); |
| selection.put("extent", semanticsNode.textSelectionExtent); |
| } |
| accessibilityChannel.dispatchSemanticsAction( |
| virtualViewId, Action.SET_SELECTION, selection); |
| // The voice access expects the semantics node to update immediately. We update the |
| // semantics node based on prediction. If the result is incorrect, it will be updated in |
| // the next frame. |
| SemanticsNode node = flutterSemanticsTree.get(virtualViewId); |
| node.textSelectionBase = selection.get("base"); |
| node.textSelectionExtent = selection.get("extent"); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_COPY: |
| { |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.COPY); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_CUT: |
| { |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.CUT); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_PASTE: |
| { |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.PASTE); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_DISMISS: |
| { |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.DISMISS); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_SET_TEXT: |
| { |
| // Set text APIs aren't available until API 21. We can't handle the case here so |
| // return false instead. It's extremely unlikely that this case would ever be |
| // triggered in the first place in API < 21. |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { |
| return false; |
| } |
| return performSetText(semanticsNode, virtualViewId, arguments); |
| } |
| default: |
| // might be a custom accessibility accessibilityAction. |
| final int flutterId = accessibilityAction - FIRST_RESOURCE_ID; |
| CustomAccessibilityAction contextAction = customAccessibilityActions.get(flutterId); |
| if (contextAction != null) { |
| accessibilityChannel.dispatchSemanticsAction( |
| virtualViewId, Action.CUSTOM_ACTION, contextAction.id); |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * Handles the responsibilities of {@link #performAction(int, int, Bundle)} for the specific |
| * scenario of cursor movement. |
| */ |
| @TargetApi(18) |
| @RequiresApi(18) |
| private boolean performCursorMoveAction( |
| @NonNull SemanticsNode semanticsNode, |
| int virtualViewId, |
| @NonNull Bundle arguments, |
| boolean forward) { |
| final int granularity = |
| arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); |
| final boolean extendSelection = |
| arguments.getBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN); |
| // The voice access expects the semantics node to update immediately. We update the semantics |
| // node based on prediction. If the result is incorrect, it will be updated in the next frame. |
| final int previousTextSelectionBase = semanticsNode.textSelectionBase; |
| final int previousTextSelectionExtent = semanticsNode.textSelectionExtent; |
| predictCursorMovement(semanticsNode, granularity, forward, extendSelection); |
| |
| if (previousTextSelectionBase != semanticsNode.textSelectionBase |
| || previousTextSelectionExtent != semanticsNode.textSelectionExtent) { |
| final String value = semanticsNode.value != null ? semanticsNode.value : ""; |
| final AccessibilityEvent selectionEvent = |
| obtainAccessibilityEvent( |
| semanticsNode.id, AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED); |
| selectionEvent.getText().add(value); |
| selectionEvent.setFromIndex(semanticsNode.textSelectionBase); |
| selectionEvent.setToIndex(semanticsNode.textSelectionExtent); |
| selectionEvent.setItemCount(value.length()); |
| sendAccessibilityEvent(selectionEvent); |
| } |
| |
| switch (granularity) { |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: |
| { |
| if (forward && semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) { |
| accessibilityChannel.dispatchSemanticsAction( |
| virtualViewId, Action.MOVE_CURSOR_FORWARD_BY_CHARACTER, extendSelection); |
| return true; |
| } |
| if (!forward && semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) { |
| accessibilityChannel.dispatchSemanticsAction( |
| virtualViewId, Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER, extendSelection); |
| return true; |
| } |
| break; |
| } |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD: |
| if (forward && semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_WORD)) { |
| accessibilityChannel.dispatchSemanticsAction( |
| virtualViewId, Action.MOVE_CURSOR_FORWARD_BY_WORD, extendSelection); |
| return true; |
| } |
| if (!forward && semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_WORD)) { |
| accessibilityChannel.dispatchSemanticsAction( |
| virtualViewId, Action.MOVE_CURSOR_BACKWARD_BY_WORD, extendSelection); |
| return true; |
| } |
| break; |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE: |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH: |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE: |
| return true; |
| } |
| return false; |
| } |
| |
| private void predictCursorMovement( |
| @NonNull SemanticsNode node, int granularity, boolean forward, boolean extendSelection) { |
| if (node.textSelectionExtent < 0 || node.textSelectionBase < 0) { |
| return; |
| } |
| |
| switch (granularity) { |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: |
| if (forward && node.textSelectionExtent < node.value.length()) { |
| node.textSelectionExtent += 1; |
| } else if (!forward && node.textSelectionExtent > 0) { |
| node.textSelectionExtent -= 1; |
| } |
| break; |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD: |
| if (forward && node.textSelectionExtent < node.value.length()) { |
| Pattern pattern = Pattern.compile("\\p{L}(\\b)"); |
| Matcher result = pattern.matcher(node.value.substring(node.textSelectionExtent)); |
| // we discard the first result because we want to find the "next" word |
| result.find(); |
| if (result.find()) { |
| node.textSelectionExtent += result.start(1); |
| } else { |
| node.textSelectionExtent = node.value.length(); |
| } |
| } else if (!forward && node.textSelectionExtent > 0) { |
| // Finds last beginning of the word boundary. |
| Pattern pattern = Pattern.compile("(?s:.*)(\\b)\\p{L}"); |
| Matcher result = pattern.matcher(node.value.substring(0, node.textSelectionExtent)); |
| if (result.find()) { |
| node.textSelectionExtent = result.start(1); |
| } |
| } |
| break; |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE: |
| if (forward && node.textSelectionExtent < node.value.length()) { |
| // Finds the next new line. |
| Pattern pattern = Pattern.compile("(?!^)(\\n)"); |
| Matcher result = pattern.matcher(node.value.substring(node.textSelectionExtent)); |
| if (result.find()) { |
| node.textSelectionExtent += result.start(1); |
| } else { |
| node.textSelectionExtent = node.value.length(); |
| } |
| } else if (!forward && node.textSelectionExtent > 0) { |
| // Finds the last new line. |
| Pattern pattern = Pattern.compile("(?s:.*)(\\n)"); |
| Matcher result = pattern.matcher(node.value.substring(0, node.textSelectionExtent)); |
| if (result.find()) { |
| node.textSelectionExtent = result.start(1); |
| } else { |
| node.textSelectionExtent = 0; |
| } |
| } |
| break; |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH: |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE: |
| if (forward) { |
| node.textSelectionExtent = node.value.length(); |
| } else { |
| node.textSelectionExtent = 0; |
| } |
| break; |
| } |
| if (!extendSelection) { |
| node.textSelectionBase = node.textSelectionExtent; |
| } |
| } |
| |
| /** |
| * Handles the responsibilities of {@link #performAction(int, int, Bundle)} for the specific |
| * scenario of cursor movement. |
| */ |
| @TargetApi(21) |
| @RequiresApi(21) |
| private boolean performSetText(SemanticsNode node, int virtualViewId, @NonNull Bundle arguments) { |
| String newText = ""; |
| if (arguments != null |
| && arguments.containsKey(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE)) { |
| newText = arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE); |
| } |
| accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SET_TEXT, newText); |
| // The voice access expects the semantics node to update immediately. Update the semantics |
| // node based on prediction. If the result is incorrect, it will be updated in the next frame. |
| node.value = newText; |
| node.valueAttributes = null; |
| return true; |
| } |
| |
| // TODO(ianh): implement findAccessibilityNodeInfosByText() |
| |
| /** |
| * Finds the view in a hierarchy that currently has the given type of {@code focus}. |
| * |
| * <p>This method is invoked by Android's accessibility system. |
| * |
| * <p>Flutter does not have an Android {@link View} hierarchy. Therefore, Flutter conceptually |
| * handles this request by searching its semantics tree for the given {@code focus}, represented |
| * by {@link #flutterSemanticsTree}. In practice, this {@code AccessibilityBridge} always caches |
| * any active {@link #accessibilityFocusedSemanticsNode} and {@link #inputFocusedSemanticsNode}. |
| * Therefore, no searching is necessary. This method directly inspects the given {@code focus} |
| * type to return one of the cached nodes, null if the cached node is null, or null if a different |
| * {@code focus} type is requested. |
| */ |
| @Override |
| public AccessibilityNodeInfo findFocus(int focus) { |
| switch (focus) { |
| case AccessibilityNodeInfo.FOCUS_INPUT: |
| { |
| if (inputFocusedSemanticsNode != null) { |
| return createAccessibilityNodeInfo(inputFocusedSemanticsNode.id); |
| } |
| if (embeddedInputFocusedNodeId != null) { |
| return createAccessibilityNodeInfo(embeddedInputFocusedNodeId); |
| } |
| } |
| // Fall through to check FOCUS_ACCESSIBILITY |
| case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY: |
| { |
| if (accessibilityFocusedSemanticsNode != null) { |
| return createAccessibilityNodeInfo(accessibilityFocusedSemanticsNode.id); |
| } |
| if (embeddedAccessibilityFocusedNodeId != null) { |
| return createAccessibilityNodeInfo(embeddedAccessibilityFocusedNodeId); |
| } |
| } |
| } |
| return null; |
| } |
| |
| /** Returns the {@link SemanticsNode} at the root of Flutter's semantics tree. */ |
| private SemanticsNode getRootSemanticsNode() { |
| if (BuildConfig.DEBUG && !flutterSemanticsTree.containsKey(0)) { |
| Log.e(TAG, "Attempted to getRootSemanticsNode without a root semantics node."); |
| } |
| return flutterSemanticsTree.get(0); |
| } |
| |
| /** |
| * Returns an existing {@link SemanticsNode} with the given {@code id}, if it exists within {@link |
| * #flutterSemanticsTree}, or creates and returns a new {@link SemanticsNode} with the given |
| * {@code id}, adding the new {@link SemanticsNode} to the {@link #flutterSemanticsTree}. |
| * |
| * <p>This method should only be invoked as a result of receiving new information from Flutter. |
| * The {@link #flutterSemanticsTree} is an Android cache of the last known state of a Flutter |
| * app's semantics tree, therefore, invoking this method in any other situation will result in a |
| * corrupt cache of Flutter's semantics tree. |
| */ |
| private SemanticsNode getOrCreateSemanticsNode(int id) { |
| SemanticsNode semanticsNode = flutterSemanticsTree.get(id); |
| if (semanticsNode == null) { |
| semanticsNode = new SemanticsNode(this); |
| semanticsNode.id = id; |
| flutterSemanticsTree.put(id, semanticsNode); |
| } |
| return semanticsNode; |
| } |
| |
| /** |
| * Returns an existing {@link CustomAccessibilityAction} with the given {@code id}, if it exists |
| * within {@link #customAccessibilityActions}, or creates and returns a new {@link |
| * CustomAccessibilityAction} with the given {@code id}, adding the new {@link |
| * CustomAccessibilityAction} to the {@link #customAccessibilityActions}. |
| * |
| * <p>This method should only be invoked as a result of receiving new information from Flutter. |
| * The {@link #customAccessibilityActions} is an Android cache of the last known state of a |
| * Flutter app's registered custom accessibility actions, therefore, invoking this method in any |
| * other situation will result in a corrupt cache of Flutter's accessibility actions. |
| */ |
| private CustomAccessibilityAction getOrCreateAccessibilityAction(int id) { |
| CustomAccessibilityAction action = customAccessibilityActions.get(id); |
| if (action == null) { |
| action = new CustomAccessibilityAction(); |
| action.id = id; |
| action.resourceId = id + FIRST_RESOURCE_ID; |
| customAccessibilityActions.put(id, action); |
| } |
| return action; |
| } |
| |
| /** |
| * A hover {@link MotionEvent} has occurred in the {@code View} that corresponds to this {@code |
| * AccessibilityBridge}. |
| * |
| * <p>This method returns true if Flutter's accessibility system handled the hover event, false |
| * otherwise. |
| * |
| * <p>This method should be invoked from the corresponding {@code View}'s {@link |
| * View#onHoverEvent(MotionEvent)}. |
| */ |
| public boolean onAccessibilityHoverEvent(MotionEvent event) { |
| return onAccessibilityHoverEvent(event, false); |
| } |
| |
| /** |
| * A hover {@link MotionEvent} has occurred in the {@code View} that corresponds to this {@code |
| * AccessibilityBridge}. |
| * |
| * <p>If {@code ignorePlatformViews} is true, if hit testing for the event finds a platform view, |
| * the event will not be handled. This is useful when handling accessibility events for views |
| * overlaying platform views. See {@code PlatformOverlayView} for details. |
| * |
| * <p>This method returns true if Flutter's accessibility system handled the hover event, false |
| * otherwise. |
| * |
| * <p>This method should be invoked from the corresponding {@code View}'s {@link |
| * View#onHoverEvent(MotionEvent)}. |
| */ |
| public boolean onAccessibilityHoverEvent(MotionEvent event, boolean ignorePlatformViews) { |
| if (!accessibilityManager.isTouchExplorationEnabled()) { |
| return false; |
| } |
| if (flutterSemanticsTree.isEmpty()) { |
| return false; |
| } |
| |
| SemanticsNode semanticsNodeUnderCursor = |
| getRootSemanticsNode() |
| .hitTest(new float[] {event.getX(), event.getY(), 0, 1}, ignorePlatformViews); |
| // semanticsNodeUnderCursor can be null when hovering over non-flutter UI such as |
| // the Android navigation bar due to hitTest() bounds checking. |
| if (semanticsNodeUnderCursor != null && semanticsNodeUnderCursor.platformViewId != -1) { |
| if (ignorePlatformViews) { |
| return false; |
| } |
| return accessibilityViewEmbedder.onAccessibilityHoverEvent( |
| semanticsNodeUnderCursor.id, event); |
| } |
| |
| if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER |
| || event.getAction() == MotionEvent.ACTION_HOVER_MOVE) { |
| handleTouchExploration(event.getX(), event.getY(), ignorePlatformViews); |
| } 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}. |
| * |
| * <p>This method informs the Android accessibility system that a {@link |
| * AccessibilityEvent#TYPE_VIEW_HOVER_EXIT} has occurred. |
| */ |
| private void onTouchExplorationExit() { |
| if (hoveredObject != null) { |
| sendAccessibilityEvent(hoveredObject.id, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); |
| hoveredObject = null; |
| } |
| } |
| |
| /** |
| * This method should be invoked when a new hover interaction begins with a {@code SemanticsNode}, |
| * or when an existing hover interaction sees a movement of the cursor. |
| * |
| * <p>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, boolean ignorePlatformViews) { |
| if (flutterSemanticsTree.isEmpty()) { |
| return; |
| } |
| SemanticsNode semanticsNodeUnderCursor = |
| getRootSemanticsNode().hitTest(new float[] {x, y, 0, 1}, ignorePlatformViews); |
| if (semanticsNodeUnderCursor != hoveredObject) { |
| // sending ENTER before EXIT is how Android wants it |
| if (semanticsNodeUnderCursor != null) { |
| sendAccessibilityEvent( |
| semanticsNodeUnderCursor.id, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER); |
| } |
| if (hoveredObject != null) { |
| sendAccessibilityEvent(hoveredObject.id, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); |
| } |
| hoveredObject = semanticsNodeUnderCursor; |
| } |
| } |
| |
| /** |
| * Updates the Android cache of Flutter's currently registered custom accessibility actions. |
| * |
| * <p>The buffer received here is encoded by PlatformViewAndroid::UpdateSemantics, and the decode |
| * logic here must be kept in sync with that method's encoding logic. |
| */ |
| // TODO(mattcarroll): Consider introducing ability to delete custom actions because they can |
| // probably come and go in Flutter, so we may want to reflect that here in |
| // the Android cache as well. |
| void updateCustomAccessibilityActions(@NonNull ByteBuffer buffer, @NonNull String[] strings) { |
| while (buffer.hasRemaining()) { |
| int id = buffer.getInt(); |
| CustomAccessibilityAction action = getOrCreateAccessibilityAction(id); |
| action.overrideId = buffer.getInt(); |
| int stringIndex = buffer.getInt(); |
| action.label = stringIndex == -1 ? null : strings[stringIndex]; |
| stringIndex = buffer.getInt(); |
| action.hint = stringIndex == -1 ? null : strings[stringIndex]; |
| } |
| } |
| |
| /** |
| * Updates {@link #flutterSemanticsTree} to reflect the latest state of Flutter's semantics tree. |
| * |
| * <p>The latest state of Flutter's semantics tree is encoded in the given {@code buffer}. The |
| * buffer is encoded by PlatformViewAndroid::UpdateSemantics, and the decode logic must be kept in |
| * sync with that method's encoding logic. |
| */ |
| void updateSemantics( |
| @NonNull ByteBuffer buffer, |
| @NonNull String[] strings, |
| @NonNull ByteBuffer[] stringAttributeArgs) { |
| ArrayList<SemanticsNode> updated = new ArrayList<>(); |
| while (buffer.hasRemaining()) { |
| int id = buffer.getInt(); |
| SemanticsNode semanticsNode = getOrCreateSemanticsNode(id); |
| semanticsNode.updateWith(buffer, strings, stringAttributeArgs); |
| if (semanticsNode.hasFlag(Flag.IS_HIDDEN)) { |
| continue; |
| } |
| if (semanticsNode.hasFlag(Flag.IS_FOCUSED)) { |
| inputFocusedSemanticsNode = semanticsNode; |
| } |
| if (semanticsNode.hadPreviousConfig) { |
| updated.add(semanticsNode); |
| } |
| if (semanticsNode.platformViewId != -1 |
| && !platformViewsAccessibilityDelegate.usesVirtualDisplay(semanticsNode.platformViewId)) { |
| View embeddedView = |
| platformViewsAccessibilityDelegate.getPlatformViewById(semanticsNode.platformViewId); |
| if (embeddedView != null) { |
| embeddedView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); |
| } |
| } |
| } |
| |
| Set<SemanticsNode> visitedObjects = new HashSet<>(); |
| SemanticsNode rootObject = getRootSemanticsNode(); |
| List<SemanticsNode> newRoutes = new ArrayList<>(); |
| if (rootObject != null) { |
| final float[] identity = new float[16]; |
| Matrix.setIdentityM(identity, 0); |
| // In Android devices API 23 and above, the system nav bar can be placed on the left side |
| // of the screen in landscape mode. We must handle the translation ourselves for the |
| // a11y nodes. |
| if (Build.VERSION.SDK_INT >= 23) { |
| boolean needsToApplyLeftCutoutInset = true; |
| // In Android devices API 28 and above, the `layoutInDisplayCutoutMode` window attribute |
| // can be set to allow overlapping content within the cutout area. Query the attribute |
| // to figure out whether the content overlaps with the cutout and decide whether to |
| // apply cutout inset. |
| if (Build.VERSION.SDK_INT >= 28) { |
| needsToApplyLeftCutoutInset = doesLayoutInDisplayCutoutModeRequireLeftInset(); |
| } |
| |
| if (needsToApplyLeftCutoutInset) { |
| WindowInsets insets = rootAccessibilityView.getRootWindowInsets(); |
| if (insets != null) { |
| if (!lastLeftFrameInset.equals(insets.getSystemWindowInsetLeft())) { |
| rootObject.globalGeometryDirty = true; |
| rootObject.inverseTransformDirty = true; |
| } |
| lastLeftFrameInset = insets.getSystemWindowInsetLeft(); |
| Matrix.translateM(identity, 0, lastLeftFrameInset, 0, 0); |
| } |
| } |
| } |
| rootObject.updateRecursively(identity, visitedObjects, false); |
| rootObject.collectRoutes(newRoutes); |
| } |
| |
| // Dispatch a TYPE_WINDOW_STATE_CHANGED event if the most recent route id changed from the |
| // previously cached route id. |
| |
| // Finds the last route that is not in the previous routes. |
| SemanticsNode lastAdded = null; |
| for (SemanticsNode semanticsNode : newRoutes) { |
| if (!flutterNavigationStack.contains(semanticsNode.id)) { |
| lastAdded = semanticsNode; |
| } |
| } |
| |
| // If all the routes are in the previous route, get the last route. |
| if (lastAdded == null && newRoutes.size() > 0) { |
| lastAdded = newRoutes.get(newRoutes.size() - 1); |
| } |
| |
| // There are two cases if lastAdded != nil |
| // 1. lastAdded is not in previous routes. In this case, |
| // lastAdded.id != previousRouteId |
| // 2. All new routes are in previous routes and |
| // lastAdded = newRoutes.last. |
| // In the first case, we need to announce new route. In the second case, |
| // we need to announce if one list is shorter than the other. |
| if (lastAdded != null |
| && (lastAdded.id != previousRouteId || newRoutes.size() != flutterNavigationStack.size())) { |
| previousRouteId = lastAdded.id; |
| onWindowNameChange(lastAdded); |
| } |
| flutterNavigationStack.clear(); |
| for (SemanticsNode semanticsNode : newRoutes) { |
| flutterNavigationStack.add(semanticsNode.id); |
| } |
| |
| Iterator<Map.Entry<Integer, SemanticsNode>> it = flutterSemanticsTree.entrySet().iterator(); |
| while (it.hasNext()) { |
| Map.Entry<Integer, SemanticsNode> entry = it.next(); |
| SemanticsNode object = entry.getValue(); |
| if (!visitedObjects.contains(object)) { |
| willRemoveSemanticsNode(object); |
| it.remove(); |
| } |
| } |
| |
| // TODO(goderbauer): Send this event only once (!) for changed subtrees, |
| // see https://github.com/flutter/flutter/issues/14534 |
| sendWindowContentChangeEvent(0); |
| |
| for (SemanticsNode object : updated) { |
| if (object.didScroll()) { |
| AccessibilityEvent event = |
| obtainAccessibilityEvent(object.id, AccessibilityEvent.TYPE_VIEW_SCROLLED); |
| |
| // Android doesn't support unbound scrolling. So we pretend there is a large |
| // bound (SCROLL_EXTENT_FOR_INFINITY), which you can never reach. |
| float position = object.scrollPosition; |
| float max = object.scrollExtentMax; |
| if (Float.isInfinite(object.scrollExtentMax)) { |
| max = SCROLL_EXTENT_FOR_INFINITY; |
| if (position > SCROLL_POSITION_CAP_FOR_INFINITY) { |
| position = SCROLL_POSITION_CAP_FOR_INFINITY; |
| } |
| } |
| if (Float.isInfinite(object.scrollExtentMin)) { |
| max += SCROLL_EXTENT_FOR_INFINITY; |
| if (position < -SCROLL_POSITION_CAP_FOR_INFINITY) { |
| position = -SCROLL_POSITION_CAP_FOR_INFINITY; |
| } |
| position += SCROLL_EXTENT_FOR_INFINITY; |
| } else { |
| max -= object.scrollExtentMin; |
| position -= object.scrollExtentMin; |
| } |
| |
| if (object.hadAction(Action.SCROLL_UP) || object.hadAction(Action.SCROLL_DOWN)) { |
| event.setScrollY((int) position); |
| event.setMaxScrollY((int) max); |
| } else if (object.hadAction(Action.SCROLL_LEFT) || object.hadAction(Action.SCROLL_RIGHT)) { |
| event.setScrollX((int) position); |
| event.setMaxScrollX((int) max); |
| } |
| if (object.scrollChildren > 0) { |
| // We don't need to add 1 to the scroll index because TalkBack does this automagically. |
| event.setItemCount(object.scrollChildren); |
| event.setFromIndex(object.scrollIndex); |
| int visibleChildren = 0; |
| // handle hidden children at the beginning and end of the list. |
| for (SemanticsNode child : object.childrenInHitTestOrder) { |
| if (!child.hasFlag(Flag.IS_HIDDEN)) { |
| visibleChildren += 1; |
| } |
| } |
| if (BuildConfig.DEBUG) { |
| if (object.scrollIndex + visibleChildren > object.scrollChildren) { |
| Log.e(TAG, "Scroll index is out of bounds."); |
| } |
| |
| if (object.childrenInHitTestOrder.isEmpty()) { |
| Log.e(TAG, "Had scrollChildren but no childrenInHitTestOrder"); |
| } |
| } |
| // The setToIndex should be the index of the last visible child. Because we counted all |
| // children, including the first index we need to subtract one. |
| // |
| // [0, 1, 2, 3, 4, 5] |
| // ^ ^ |
| // In the example above where 0 is the first visible index and 2 is the last, we will |
| // count 3 total visible children. We then subtract one to get the correct last visible |
| // index of 2. |
| event.setToIndex(object.scrollIndex + visibleChildren - 1); |
| } |
| sendAccessibilityEvent(event); |
| } |
| if (object.hasFlag(Flag.IS_LIVE_REGION) && object.didChangeLabel()) { |
| sendWindowContentChangeEvent(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 the object is the input-focused node, then tell the reader about it, but only if |
| // it has changed since the last update. |
| if (inputFocusedSemanticsNode != null |
| && inputFocusedSemanticsNode.id == object.id |
| && (lastInputFocusedSemanticsNode == null |
| || lastInputFocusedSemanticsNode.id != inputFocusedSemanticsNode.id)) { |
| lastInputFocusedSemanticsNode = inputFocusedSemanticsNode; |
| sendAccessibilityEvent( |
| obtainAccessibilityEvent(object.id, AccessibilityEvent.TYPE_VIEW_FOCUSED)); |
| } else if (inputFocusedSemanticsNode == null) { |
| // There's no TYPE_VIEW_CLEAR_FOCUSED event, so if the current input focus becomes |
| // null, then we just set the last one to null too, so that it sends the event again |
| // when something regains focus. |
| lastInputFocusedSemanticsNode = null; |
| } |
| |
| 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 |
| && (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); |
| if (event != null) { |
| sendAccessibilityEvent(event); |
| } |
| |
| if (object.previousTextSelectionBase != object.textSelectionBase |
| || object.previousTextSelectionExtent != object.textSelectionExtent) { |
| AccessibilityEvent selectionEvent = |
| obtainAccessibilityEvent( |
| object.id, AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED); |
| selectionEvent.getText().add(newValue); |
| selectionEvent.setFromIndex(object.textSelectionBase); |
| selectionEvent.setToIndex(object.textSelectionExtent); |
| selectionEvent.setItemCount(newValue.length()); |
| sendAccessibilityEvent(selectionEvent); |
| } |
| } |
| } |
| } |
| |
| private AccessibilityEvent createTextChangedEvent(int id, String oldValue, String newValue) { |
| AccessibilityEvent e = obtainAccessibilityEvent(id, AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED); |
| e.setBeforeText(oldValue); |
| e.getText().add(newValue); |
| |
| int i; |
| for (i = 0; i < oldValue.length() && i < newValue.length(); ++i) { |
| if (oldValue.charAt(i) != newValue.charAt(i)) { |
| break; |
| } |
| } |
| if (i >= oldValue.length() && i >= newValue.length()) { |
| return null; // Text did not change |
| } |
| int firstDifference = i; |
| e.setFromIndex(firstDifference); |
| |
| int oldIndex = oldValue.length() - 1; |
| int newIndex = newValue.length() - 1; |
| while (oldIndex >= firstDifference && newIndex >= firstDifference) { |
| if (oldValue.charAt(oldIndex) != newValue.charAt(newIndex)) { |
| break; |
| } |
| --oldIndex; |
| --newIndex; |
| } |
| e.setRemovedCount(oldIndex - firstDifference + 1); |
| e.setAddedCount(newIndex - firstDifference + 1); |
| |
| return e; |
| } |
| |
| /** |
| * 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. |
| * |
| * <p>The given {@code viewId} may either belong to {@link #rootAccessibilityView}, or any Flutter |
| * {@link SemanticsNode}. |
| */ |
| @VisibleForTesting |
| public void sendAccessibilityEvent(int viewId, int eventType) { |
| if (!accessibilityManager.isEnabled()) { |
| return; |
| } |
| sendAccessibilityEvent(obtainAccessibilityEvent(viewId, eventType)); |
| } |
| |
| /** |
| * Sends the given {@link AccessibilityEvent} to Android's accessibility system for a given |
| * Flutter {@link SemanticsNode}. |
| * |
| * <p>This method should only be called for a Flutter {@link SemanticsNode}, not a traditional |
| * Android {@code View}, i.e., {@link #rootAccessibilityView}. |
| */ |
| private void sendAccessibilityEvent(@NonNull AccessibilityEvent event) { |
| if (!accessibilityManager.isEnabled()) { |
| return; |
| } |
| // See |
| // https://developer.android.com/reference/android/view/View.html#sendAccessibilityEvent(int) |
| // We just want the final part at this point, since the event parameter |
| // has already been correctly populated. |
| rootAccessibilityView.getParent().requestSendAccessibilityEvent(rootAccessibilityView, event); |
| } |
| |
| /** |
| * Informs the TalkBack user about window name changes. |
| * |
| * <p>This method sets accessibility panel title if the API level >= 28, otherwise, it creates a |
| * {@link AccessibilityEvent#TYPE_WINDOW_STATE_CHANGED} and sends the event to Android's |
| * accessibility system. In both cases, TalkBack announces the label of the route and re-addjusts |
| * the accessibility focus. |
| * |
| * <p>The given {@code route} should be a {@link SemanticsNode} that represents a navigation route |
| * in the Flutter app. |
| */ |
| private void onWindowNameChange(@NonNull SemanticsNode route) { |
| String routeName = route.getRouteName(); |
| if (routeName == null) { |
| // The routeName will be null when there is no semantics node that represnets namesRoute in |
| // the scopeRoute. The TYPE_WINDOW_STATE_CHANGED only works the route name is not null and not |
| // empty. Gives it a whitespace will make it focus the first semantics node without |
| // pronouncing any word. |
| // |
| // The other way to trigger a focus change is to send a TYPE_VIEW_FOCUSED to the |
| // rootAccessibilityView. However, it is less predictable which semantics node it will focus |
| // next. |
| routeName = " "; |
| } |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { |
| setAccessibilityPaneTitle(routeName); |
| } else { |
| AccessibilityEvent event = |
| obtainAccessibilityEvent(route.id, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); |
| event.getText().add(routeName); |
| sendAccessibilityEvent(event); |
| } |
| } |
| |
| @TargetApi(28) |
| @RequiresApi(28) |
| private void setAccessibilityPaneTitle(String title) { |
| rootAccessibilityView.setAccessibilityPaneTitle(title); |
| } |
| |
| /** |
| * Creates a {@link AccessibilityEvent#TYPE_WINDOW_CONTENT_CHANGED} and sends the event to |
| * Android's accessibility system. |
| * |
| * <p>It sets the content change types to {@link AccessibilityEvent#CONTENT_CHANGE_TYPE_SUBTREE} |
| * when supported by the API level. |
| * |
| * <p>The given {@code virtualViewId} should be a {@link SemanticsNode} below which the content |
| * has changed. |
| */ |
| private void sendWindowContentChangeEvent(int virtualViewId) { |
| AccessibilityEvent event = |
| obtainAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { |
| event.setContentChangeTypes(AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE); |
| } |
| sendAccessibilityEvent(event); |
| } |
| |
| /** |
| * Factory method that creates a new {@link AccessibilityEvent} that is configured to represent |
| * the Flutter {@link SemanticsNode} represented by the given {@code virtualViewId}, categorized |
| * as the given {@code eventType}. |
| * |
| * <p>This method should *only* be called for Flutter {@link SemanticsNode}s. It should *not* be |
| * invoked to create an {@link AccessibilityEvent} for the {@link #rootAccessibilityView}. |
| */ |
| private AccessibilityEvent obtainAccessibilityEvent(int virtualViewId, int eventType) { |
| AccessibilityEvent event = obtainAccessibilityEvent(eventType); |
| event.setPackageName(rootAccessibilityView.getContext().getPackageName()); |
| event.setSource(rootAccessibilityView, virtualViewId); |
| return event; |
| } |
| |
| @VisibleForTesting |
| public AccessibilityEvent obtainAccessibilityEvent(int eventType) { |
| return AccessibilityEvent.obtain(eventType); |
| } |
| |
| /** |
| * Reads the {@code layoutInDisplayCutoutMode} value from the window attribute and returns whether |
| * a left cutout inset is required. |
| * |
| * <p>The {@code layoutInDisplayCutoutMode} is added after API level 28. |
| */ |
| @TargetApi(28) |
| @RequiresApi(28) |
| private boolean doesLayoutInDisplayCutoutModeRequireLeftInset() { |
| Context context = rootAccessibilityView.getContext(); |
| Activity activity = ViewUtils.getActivity(context); |
| if (activity == null || activity.getWindow() == null) { |
| // The activity is not visible, it does not matter whether to apply left inset |
| // or not. |
| return false; |
| } |
| int layoutInDisplayCutoutMode = activity.getWindow().getAttributes().layoutInDisplayCutoutMode; |
| return layoutInDisplayCutoutMode |
| == WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER |
| || layoutInDisplayCutoutMode |
| == WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; |
| } |
| |
| /** |
| * Hook called just before a {@link SemanticsNode} is removed from the Android cache of Flutter's |
| * semantics tree. |
| */ |
| @TargetApi(19) |
| @RequiresApi(19) |
| private void willRemoveSemanticsNode(SemanticsNode semanticsNodeToBeRemoved) { |
| if (BuildConfig.DEBUG) { |
| if (!flutterSemanticsTree.containsKey(semanticsNodeToBeRemoved.id)) { |
| Log.e(TAG, "Attempted to remove a node that is not in the tree."); |
| } |
| if (flutterSemanticsTree.get(semanticsNodeToBeRemoved.id) != semanticsNodeToBeRemoved) { |
| Log.e(TAG, "Flutter semantics tree failed to get expected node when searching by id."); |
| } |
| } |
| // TODO(mattcarroll): should parent be set to "null" here? Changing the parent seems like the |
| // behavior of a method called "removeSemanticsNode()". The same is true |
| // for null'ing accessibilityFocusedSemanticsNode, inputFocusedSemanticsNode, |
| // and hoveredObject. Is this a hook method or a command? |
| semanticsNodeToBeRemoved.parent = null; |
| |
| if (semanticsNodeToBeRemoved.platformViewId != -1 |
| && embeddedAccessibilityFocusedNodeId != null |
| && accessibilityViewEmbedder.platformViewOfNode(embeddedAccessibilityFocusedNodeId) |
| == platformViewsAccessibilityDelegate.getPlatformViewById( |
| semanticsNodeToBeRemoved.platformViewId)) { |
| // If the currently focused a11y node is within a platform view that is |
| // getting removed: clear it's a11y focus. |
| sendAccessibilityEvent( |
| embeddedAccessibilityFocusedNodeId, |
| AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); |
| embeddedAccessibilityFocusedNodeId = null; |
| } |
| |
| if (semanticsNodeToBeRemoved.platformViewId != -1) { |
| View embeddedView = |
| platformViewsAccessibilityDelegate.getPlatformViewById( |
| semanticsNodeToBeRemoved.platformViewId); |
| if (embeddedView != null) { |
| embeddedView.setImportantForAccessibility( |
| View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); |
| } |
| } |
| |
| if (accessibilityFocusedSemanticsNode == semanticsNodeToBeRemoved) { |
| sendAccessibilityEvent( |
| accessibilityFocusedSemanticsNode.id, |
| AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); |
| accessibilityFocusedSemanticsNode = null; |
| } |
| |
| if (inputFocusedSemanticsNode == semanticsNodeToBeRemoved) { |
| inputFocusedSemanticsNode = null; |
| } |
| |
| if (hoveredObject == semanticsNodeToBeRemoved) { |
| hoveredObject = null; |
| } |
| } |
| |
| /** |
| * Resets the {@code AccessibilityBridge}: |
| * |
| * <ul> |
| * <li>Clears {@link #flutterSemanticsTree}, the Android cache of Flutter's semantics tree |
| * <li>Releases focus on any active {@link #accessibilityFocusedSemanticsNode} |
| * <li>Clears any hovered {@code SemanticsNode} |
| * <li>Sends a {@link AccessibilityEvent#TYPE_WINDOW_CONTENT_CHANGED} event |
| * </ul> |
| */ |
| // TODO(mattcarroll): under what conditions is this method expected to be invoked? |
| public void reset() { |
| flutterSemanticsTree.clear(); |
| if (accessibilityFocusedSemanticsNode != null) { |
| sendAccessibilityEvent( |
| accessibilityFocusedSemanticsNode.id, |
| AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); |
| } |
| accessibilityFocusedSemanticsNode = null; |
| hoveredObject = null; |
| sendWindowContentChangeEvent(0); |
| } |
| |
| /** |
| * Listener that can be set on a {@link AccessibilityBridge}, which is invoked any time |
| * accessibility is turned on/off, or touch exploration is turned on/off. |
| */ |
| public interface OnAccessibilityChangeListener { |
| void onAccessibilityChanged(boolean isAccessibilityEnabled, boolean isTouchExplorationEnabled); |
| } |
| |
| // Must match SemanticsActions in semantics.dart |
| // https://github.com/flutter/engine/blob/main/lib/ui/semantics.dart |
| public enum Action { |
| TAP(1 << 0), |
| LONG_PRESS(1 << 1), |
| SCROLL_LEFT(1 << 2), |
| SCROLL_RIGHT(1 << 3), |
| SCROLL_UP(1 << 4), |
| SCROLL_DOWN(1 << 5), |
| INCREASE(1 << 6), |
| DECREASE(1 << 7), |
| SHOW_ON_SCREEN(1 << 8), |
| MOVE_CURSOR_FORWARD_BY_CHARACTER(1 << 9), |
| MOVE_CURSOR_BACKWARD_BY_CHARACTER(1 << 10), |
| SET_SELECTION(1 << 11), |
| COPY(1 << 12), |
| CUT(1 << 13), |
| PASTE(1 << 14), |
| DID_GAIN_ACCESSIBILITY_FOCUS(1 << 15), |
| DID_LOSE_ACCESSIBILITY_FOCUS(1 << 16), |
| CUSTOM_ACTION(1 << 17), |
| DISMISS(1 << 18), |
| MOVE_CURSOR_FORWARD_BY_WORD(1 << 19), |
| MOVE_CURSOR_BACKWARD_BY_WORD(1 << 20), |
| SET_TEXT(1 << 21); |
| |
| public final int value; |
| |
| Action(int value) { |
| this.value = value; |
| } |
| } |
| |
| // Actions that are triggered by Android OS, as opposed to user-triggered actions. |
| // |
| // This int is intended to be use in a bitwise comparison. |
| static int systemAction = |
| Action.DID_GAIN_ACCESSIBILITY_FOCUS.value |
| & Action.DID_LOSE_ACCESSIBILITY_FOCUS.value |
| & Action.SHOW_ON_SCREEN.value; |
| |
| // Must match SemanticsFlag in semantics.dart |
| // https://github.com/flutter/engine/blob/main/lib/ui/semantics.dart |
| /* Package */ enum Flag { |
| HAS_CHECKED_STATE(1 << 0), |
| IS_CHECKED(1 << 1), |
| IS_SELECTED(1 << 2), |
| IS_BUTTON(1 << 3), |
| IS_TEXT_FIELD(1 << 4), |
| IS_FOCUSED(1 << 5), |
| HAS_ENABLED_STATE(1 << 6), |
| IS_ENABLED(1 << 7), |
| IS_IN_MUTUALLY_EXCLUSIVE_GROUP(1 << 8), |
| IS_HEADER(1 << 9), |
| IS_OBSCURED(1 << 10), |
| SCOPES_ROUTE(1 << 11), |
| NAMES_ROUTE(1 << 12), |
| IS_HIDDEN(1 << 13), |
| IS_IMAGE(1 << 14), |
| IS_LIVE_REGION(1 << 15), |
| HAS_TOGGLED_STATE(1 << 16), |
| IS_TOGGLED(1 << 17), |
| HAS_IMPLICIT_SCROLLING(1 << 18), |
| IS_MULTILINE(1 << 19), |
| IS_READ_ONLY(1 << 20), |
| IS_FOCUSABLE(1 << 21), |
| IS_LINK(1 << 22), |
| IS_SLIDER(1 << 23), |
| IS_KEYBOARD_KEY(1 << 24), |
| IS_CHECK_STATE_MIXED(1 << 25), |
| HAS_EXPANDED_STATE(1 << 26), |
| IS_EXPANDED(1 << 27); |
| |
| final int value; |
| |
| Flag(int value) { |
| this.value = value; |
| } |
| } |
| |
| // Must match the enum defined in window.dart. |
| private enum AccessibilityFeature { |
| ACCESSIBLE_NAVIGATION(1 << 0), |
| INVERT_COLORS(1 << 1), // NOT SUPPORTED |
| DISABLE_ANIMATIONS(1 << 2), |
| BOLD_TEXT(1 << 3), // NOT SUPPORTED |
| REDUCE_MOTION(1 << 4), // NOT SUPPORTED |
| HIGH_CONTRAST(1 << 5), // NOT SUPPORTED |
| ON_OFF_SWITCH_LABELS(1 << 6); // NOT SUPPORTED |
| |
| final int value; |
| |
| AccessibilityFeature(int value) { |
| this.value = value; |
| } |
| } |
| |
| private enum TextDirection { |
| UNKNOWN, |
| LTR, |
| RTL; |
| |
| public static TextDirection fromInt(int value) { |
| switch (value) { |
| case 1: |
| return RTL; |
| case 2: |
| return LTR; |
| } |
| return UNKNOWN; |
| } |
| } |
| |
| /** |
| * Accessibility action that is defined within a given Flutter application, as opposed to the |
| * standard accessibility actions that are available in the Flutter framework. |
| * |
| * <p>Flutter and Android support a number of built-in accessibility actions. However, these |
| * predefined actions are not always sufficient for a desired interaction. Android facilitates |
| * custom accessibility actions, |
| * https://developer.android.com/reference/android/view/accessibility/AccessibilityNodeInfo.AccessibilityAction. |
| * Flutter supports custom accessibility actions via {@code customSemanticsActions} within a |
| * {@code Semantics} widget, https://api.flutter.dev/flutter/widgets/Semantics-class.html. |
| * |
| * <p>See the Android documentation for custom accessibility actions: |
| * https://developer.android.com/reference/android/view/accessibility/AccessibilityNodeInfo.AccessibilityAction |
| * |
| * <p>See the Flutter documentation for the Semantics widget: |
| * https://api.flutter.dev/flutter/widgets/Semantics-class.html |
| */ |
| private static class CustomAccessibilityAction { |
| CustomAccessibilityAction() {} |
| |
| // The ID of the custom action plus a minimum value so that the identifier |
| // does not collide with existing Android accessibility actions. This ID |
| // represents and Android resource ID, not a Flutter ID. |
| private int resourceId = -1; |
| |
| // The Flutter ID of this custom accessibility action. See Flutter's Semantics widget for |
| // custom accessibility action definitions: |
| // https://api.flutter.dev/flutter/widgets/Semantics-class.html |
| private int id = -1; |
| |
| // The ID of the standard Flutter accessibility action that this {@code |
| // CustomAccessibilityAction} |
| // overrides with a custom {@code label} and/or {@code hint}. |
| private int overrideId = -1; |
| |
| // The user presented value which is displayed in the local context menu. |
| private String label; |
| |
| // The text used in overridden standard actions. |
| private String hint; |
| } |
| |
| // When adding a new StringAttributeType, the classes in these file must be |
| // updated as well. |
| // * engine/src/flutter/lib/ui/semantics.dart |
| // * engine/src/flutter/lib/web_ui/lib/semantics.dart |
| // * engine/src/flutter/lib/ui/semantics/string_attribute.h |
| |
| private enum StringAttributeType { |
| SPELLOUT, |
| LOCALE, |
| } |
| |
| private static class StringAttribute { |
| int start; |
| int end; |
| StringAttributeType type; |
| } |
| |
| private static class SpellOutStringAttribute extends StringAttribute {} |
| |
| private static class LocaleStringAttribute extends StringAttribute { |
| String locale; |
| } |
| |
| /** |
| * Flutter {@code SemanticsNode} represented in Java/Android. |
| * |
| * <p>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. |
| * |
| * <p>To see how this {@code SemanticsNode}'s fields correspond to Flutter's semantics system, see |
| * semantics.dart: https://github.com/flutter/engine/blob/main/lib/ui/semantics.dart |
| */ |
| private static class SemanticsNode { |
| private static boolean nullableHasAncestor( |
| SemanticsNode target, Predicate<SemanticsNode> tester) { |
| return target != null && target.getAncestor(tester) != null; |
| } |
| |
| final AccessibilityBridge accessibilityBridge; |
| |
| // Flutter ID of this {@code SemanticsNode}. |
| private int id = -1; |
| |
| private int flags; |
| private int actions; |
| private int maxValueLength; |
| private int currentValueLength; |
| private int textSelectionBase; |
| private int textSelectionExtent; |
| private int platformViewId; |
| private int scrollChildren; |
| private int scrollIndex; |
| private float scrollPosition; |
| private float scrollExtentMax; |
| private float scrollExtentMin; |
| private String identifier; |
| private String label; |
| private List<StringAttribute> labelAttributes; |
| private String value; |
| private List<StringAttribute> valueAttributes; |
| private String increasedValue; |
| private List<StringAttribute> increasedValueAttributes; |
| private String decreasedValue; |
| private List<StringAttribute> decreasedValueAttributes; |
| private String hint; |
| private List<StringAttribute> hintAttributes; |
| |
| // The textual description of the backing widget's tooltip. |
| // |
| // The tooltip is attached through AccessibilityNodeInfo.setTooltipText if |
| // API level >= 28; otherwise, this is attached to the end of content description. |
| @Nullable private String tooltip; |
| |
| // The id of the sibling node that is before this node in traversal |
| // order. |
| // |
| // The child order alone does not guarantee the TalkBack focus traversal |
| // order. The AccessibilityNodeInfo.setTraversalAfter must be called with |
| // its previous sibling to determine the focus traversal order. |
| // |
| // This property is updated in AccessibilityBridge.updateRecursively, |
| // which is called at the end of every semantics update, and it is used in |
| // AccessibilityBridge.createAccessibilityNodeInfo to set the "traversal |
| // after" of this node. |
| private int previousNodeId = -1; |
| |
| // 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; |
| private float right; |
| private float bottom; |
| private float[] transform; |
| |
| private SemanticsNode parent; |
| private List<SemanticsNode> childrenInTraversalOrder = new ArrayList<>(); |
| private List<SemanticsNode> childrenInHitTestOrder = new ArrayList<>(); |
| private List<CustomAccessibilityAction> customAccessibilityActions; |
| private CustomAccessibilityAction onTapOverride; |
| private CustomAccessibilityAction onLongPressOverride; |
| |
| private boolean inverseTransformDirty = true; |
| private float[] inverseTransform; |
| |
| private boolean globalGeometryDirty = true; |
| private float[] globalTransform; |
| private Rect globalRect; |
| |
| SemanticsNode(@NonNull AccessibilityBridge accessibilityBridge) { |
| this.accessibilityBridge = accessibilityBridge; |
| } |
| |
| /** |
| * Returns the ancestor of this {@code SemanticsNode} for which {@link Predicate#test(Object)} |
| * returns true, or null if no such ancestor exists. |
| */ |
| private SemanticsNode getAncestor(Predicate<SemanticsNode> tester) { |
| SemanticsNode nextAncestor = parent; |
| while (nextAncestor != null) { |
| if (tester.test(nextAncestor)) { |
| return nextAncestor; |
| } |
| nextAncestor = nextAncestor.parent; |
| } |
| return null; |
| } |
| |
| /** |
| * Returns true if the given {@code action} is supported by this {@code SemanticsNode}. |
| * |
| * <p>This method only applies to this {@code SemanticsNode} and does not implicitly search its |
| * children. |
| */ |
| private boolean hasAction(@NonNull Action action) { |
| return (actions & action.value) != 0; |
| } |
| |
| /** |
| * Returns true if the given {@code action} was supported by the immediately previous version of |
| * this {@code SemanticsNode}. |
| */ |
| private boolean hadAction(@NonNull Action action) { |
| return (previousActions & action.value) != 0; |
| } |
| |
| private boolean hasFlag(@NonNull Flag flag) { |
| return (flags & flag.value) != 0; |
| } |
| |
| private boolean hadFlag(@NonNull Flag flag) { |
| if (BuildConfig.DEBUG && !hadPreviousConfig) { |
| Log.e(TAG, "Attempted to check hadFlag but had no previous config."); |
| } |
| return (previousFlags & flag.value) != 0; |
| } |
| |
| private boolean didScroll() { |
| return !Float.isNaN(scrollPosition) |
| && !Float.isNaN(previousScrollPosition) |
| && previousScrollPosition != scrollPosition; |
| } |
| |
| private boolean didChangeLabel() { |
| if (label == null && previousLabel == null) { |
| return false; |
| } |
| return label == null || previousLabel == null || !label.equals(previousLabel); |
| } |
| |
| private void log(@NonNull String indent, boolean recursive) { |
| if (BuildConfig.DEBUG) { |
| Log.i( |
| TAG, |
| indent |
| + "SemanticsNode id=" |
| + id |
| + " identifier=" |
| + identifier |
| + " label=" |
| + label |
| + " actions=" |
| + actions |
| + " flags=" |
| + flags |
| + "\n" |
| + indent |
| + " +-- textDirection=" |
| + textDirection |
| + "\n" |
| + indent |
| + " +-- rect.ltrb=(" |
| + left |
| + ", " |
| + top |
| + ", " |
| + right |
| + ", " |
| + bottom |
| + ")\n" |
| + indent |
| + " +-- transform=" |
| + Arrays.toString(transform) |
| + "\n"); |
| if (recursive) { |
| String childIndent = indent + " "; |
| for (SemanticsNode child : childrenInTraversalOrder) { |
| child.log(childIndent, recursive); |
| } |
| } |
| } |
| } |
| |
| private void updateWith( |
| @NonNull ByteBuffer buffer, |
| @NonNull String[] strings, |
| @NonNull ByteBuffer[] stringAttributeArgs) { |
| hadPreviousConfig = true; |
| previousValue = value; |
| previousLabel = label; |
| previousFlags = flags; |
| previousActions = actions; |
| previousTextSelectionBase = textSelectionBase; |
| previousTextSelectionExtent = textSelectionExtent; |
| previousScrollPosition = scrollPosition; |
| previousScrollExtentMax = scrollExtentMax; |
| previousScrollExtentMin = scrollExtentMin; |
| |
| flags = buffer.getInt(); |
| actions = buffer.getInt(); |
| maxValueLength = buffer.getInt(); |
| currentValueLength = buffer.getInt(); |
| textSelectionBase = buffer.getInt(); |
| textSelectionExtent = buffer.getInt(); |
| platformViewId = buffer.getInt(); |
| scrollChildren = buffer.getInt(); |
| scrollIndex = buffer.getInt(); |
| scrollPosition = buffer.getFloat(); |
| scrollExtentMax = buffer.getFloat(); |
| scrollExtentMin = buffer.getFloat(); |
| |
| int stringIndex = buffer.getInt(); |
| |
| identifier = stringIndex == -1 ? null : strings[stringIndex]; |
| stringIndex = buffer.getInt(); |
| |
| label = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| labelAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs); |
| |
| stringIndex = buffer.getInt(); |
| value = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| valueAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs); |
| |
| stringIndex = buffer.getInt(); |
| increasedValue = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| increasedValueAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs); |
| |
| stringIndex = buffer.getInt(); |
| decreasedValue = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| decreasedValueAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs); |
| |
| stringIndex = buffer.getInt(); |
| hint = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| hintAttributes = getStringAttributesFromBuffer(buffer, stringAttributeArgs); |
| |
| stringIndex = buffer.getInt(); |
| tooltip = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| textDirection = TextDirection.fromInt(buffer.getInt()); |
| |
| left = buffer.getFloat(); |
| top = buffer.getFloat(); |
| right = buffer.getFloat(); |
| bottom = buffer.getFloat(); |
| |
| if (transform == null) { |
| transform = new float[16]; |
| } |
| for (int i = 0; i < 16; ++i) { |
| transform[i] = buffer.getFloat(); |
| } |
| inverseTransformDirty = true; |
| globalGeometryDirty = true; |
| |
| final int childCount = buffer.getInt(); |
| childrenInTraversalOrder.clear(); |
| childrenInHitTestOrder.clear(); |
| for (int i = 0; i < childCount; ++i) { |
| SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt()); |
| child.parent = this; |
| childrenInTraversalOrder.add(child); |
| } |
| for (int i = 0; i < childCount; ++i) { |
| SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt()); |
| child.parent = this; |
| childrenInHitTestOrder.add(child); |
| } |
| |
| final int actionCount = buffer.getInt(); |
| if (actionCount == 0) { |
| customAccessibilityActions = null; |
| } else { |
| if (customAccessibilityActions == null) |
| customAccessibilityActions = new ArrayList<>(actionCount); |
| else customAccessibilityActions.clear(); |
| |
| for (int i = 0; i < actionCount; i++) { |
| CustomAccessibilityAction action = |
| accessibilityBridge.getOrCreateAccessibilityAction(buffer.getInt()); |
| if (action.overrideId == Action.TAP.value) { |
| onTapOverride = action; |
| } else if (action.overrideId == Action.LONG_PRESS.value) { |
| onLongPressOverride = action; |
| } else { |
| // If we receive a different overrideId it means that we were passed |
| // a standard action to override that we don't yet support. |
| if (BuildConfig.DEBUG && action.overrideId != -1) { |
| Log.e(TAG, "Expected action.overrideId to be -1."); |
| } |
| customAccessibilityActions.add(action); |
| } |
| customAccessibilityActions.add(action); |
| } |
| } |
| } |
| |
| private List<StringAttribute> getStringAttributesFromBuffer( |
| @NonNull ByteBuffer buffer, @NonNull ByteBuffer[] stringAttributeArgs) { |
| final int attributesCount = buffer.getInt(); |
| if (attributesCount == -1) { |
| return null; |
| } |
| final List<StringAttribute> result = new ArrayList<>(attributesCount); |
| for (int i = 0; i < attributesCount; ++i) { |
| final int start = buffer.getInt(); |
| final int end = buffer.getInt(); |
| final StringAttributeType type = StringAttributeType.values()[buffer.getInt()]; |
| switch (type) { |
| case SPELLOUT: |
| { |
| // Pops the -1 size. |
| buffer.getInt(); |
| SpellOutStringAttribute attribute = new SpellOutStringAttribute(); |
| attribute.start = start; |
| attribute.end = end; |
| attribute.type = type; |
| result.add(attribute); |
| break; |
| } |
| case LOCALE: |
| { |
| final int argsIndex = buffer.getInt(); |
| final ByteBuffer args = stringAttributeArgs[argsIndex]; |
| LocaleStringAttribute attribute = new LocaleStringAttribute(); |
| attribute.start = start; |
| attribute.end = end; |
| attribute.type = type; |
| attribute.locale = Charset.forName("UTF-8").decode(args).toString(); |
| result.add(attribute); |
| break; |
| } |
| default: |
| break; |
| } |
| } |
| return result; |
| } |
| |
| private void ensureInverseTransform() { |
| if (!inverseTransformDirty) { |
| return; |
| } |
| inverseTransformDirty = false; |
| if (inverseTransform == null) { |
| inverseTransform = new float[16]; |
| } |
| if (!Matrix.invertM(inverseTransform, 0, transform, 0)) { |
| Arrays.fill(inverseTransform, 0); |
| } |
| } |
| |
| private Rect getGlobalRect() { |
| if (BuildConfig.DEBUG && globalGeometryDirty) { |
| Log.e(TAG, "Attempted to getGlobalRect with a dirty geometry."); |
| } |
| return globalRect; |
| } |
| |
| /** |
| * Hit tests {@code point} to find the deepest focusable node in the node tree at that point. |
| * |
| * @param point The point to hit test against this node. |
| * @param stopAtPlatformView Whether to return a platform view if found, regardless of whether |
| * or not it is focusable. |
| * @return The found node, or null if no relevant node was found at the given point. |
| */ |
| private SemanticsNode hitTest(float[] point, boolean stopAtPlatformView) { |
| final float w = point[3]; |
| final float x = point[0] / w; |
| final float y = point[1] / w; |
| if (x < left || x >= right || y < top || y >= bottom) return null; |
| final float[] transformedPoint = new float[4]; |
| for (SemanticsNode child : childrenInHitTestOrder) { |
| if (child.hasFlag(Flag.IS_HIDDEN)) { |
| continue; |
| } |
| child.ensureInverseTransform(); |
| Matrix.multiplyMV(transformedPoint, 0, child.inverseTransform, 0, point, 0); |
| final SemanticsNode result = child.hitTest(transformedPoint, stopAtPlatformView); |
| if (result != null) { |
| return result; |
| } |
| } |
| final boolean foundPlatformView = stopAtPlatformView && platformViewId != -1; |
| return isFocusable() || foundPlatformView ? this : null; |
| } |
| |
| // TODO(goderbauer): This should be decided by the framework once we have more information |
| // about focusability there. |
| private boolean isFocusable() { |
| // We enforce in the framework that no other useful semantics are merged with these |
| // nodes. |
| if (hasFlag(Flag.SCOPES_ROUTE)) { |
| return false; |
| } |
| if (hasFlag(Flag.IS_FOCUSABLE)) { |
| return true; |
| } |
| // If not explicitly set as focusable, then use our legacy |
| // algorithm. Once all focusable widgets have a Focus widget, then |
| // this won't be needed. |
| return (actions & ~SCROLLABLE_ACTIONS) != 0 |
| || (flags & FOCUSABLE_FLAGS) != 0 |
| || (label != null && !label.isEmpty()) |
| || (value != null && !value.isEmpty()) |
| || (hint != null && !hint.isEmpty()); |
| } |
| |
| private void collectRoutes(List<SemanticsNode> edges) { |
| if (hasFlag(Flag.SCOPES_ROUTE)) { |
| edges.add(this); |
| } |
| for (SemanticsNode child : childrenInTraversalOrder) { |
| child.collectRoutes(edges); |
| } |
| } |
| |
| 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)) { |
| if (label != null && !label.isEmpty()) { |
| return label; |
| } |
| } |
| for (SemanticsNode child : childrenInTraversalOrder) { |
| String newName = child.getRouteName(); |
| if (newName != null && !newName.isEmpty()) { |
| return newName; |
| } |
| } |
| return null; |
| } |
| |
| private void updateRecursively( |
| float[] ancestorTransform, Set<SemanticsNode> visitedObjects, boolean forceUpdate) { |
| visitedObjects.add(this); |
| |
| if (globalGeometryDirty) { |
| forceUpdate = true; |
| } |
| |
| if (forceUpdate) { |
| if (globalTransform == null) { |
| globalTransform = new float[16]; |
| } |
| if (transform == null) { |
| if (BuildConfig.DEBUG) { |
| Log.e(TAG, "transform has not been initialized for id = " + id); |
| accessibilityBridge.getRootSemanticsNode().log("Semantics tree:", true); |
| } |
| transform = new float[16]; |
| } |
| Matrix.multiplyMM(globalTransform, 0, ancestorTransform, 0, transform, 0); |
| |
| final float[] sample = new float[4]; |
| sample[2] = 0; |
| sample[3] = 1; |
| |
| final float[] point1 = new float[4]; |
| final float[] point2 = new float[4]; |
| final float[] point3 = new float[4]; |
| final float[] point4 = new float[4]; |
| |
| sample[0] = left; |
| sample[1] = top; |
| transformPoint(point1, globalTransform, sample); |
| |
| sample[0] = right; |
| sample[1] = top; |
| transformPoint(point2, globalTransform, sample); |
| |
| sample[0] = right; |
| sample[1] = bottom; |
| transformPoint(point3, globalTransform, sample); |
| |
| sample[0] = left; |
| sample[1] = bottom; |
| transformPoint(point4, globalTransform, sample); |
| |
| if (globalRect == null) globalRect = new Rect(); |
| |
| globalRect.set( |
| Math.round(min(point1[0], point2[0], point3[0], point4[0])), |
| Math.round(min(point1[1], point2[1], point3[1], point4[1])), |
| Math.round(max(point1[0], point2[0], point3[0], point4[0])), |
| Math.round(max(point1[1], point2[1], point3[1], point4[1]))); |
| |
| globalGeometryDirty = false; |
| } |
| |
| if (BuildConfig.DEBUG) { |
| if (globalTransform == null) { |
| Log.e(TAG, "Expected globalTransform to not be null."); |
| } |
| if (globalRect == null) { |
| Log.e(TAG, "Expected globalRect to not be null."); |
| } |
| } |
| |
| int previousNodeId = -1; |
| for (SemanticsNode child : childrenInTraversalOrder) { |
| child.previousNodeId = previousNodeId; |
| previousNodeId = child.id; |
| child.updateRecursively(globalTransform, visitedObjects, forceUpdate); |
| } |
| } |
| |
| private void transformPoint(float[] result, float[] transform, float[] point) { |
| Matrix.multiplyMV(result, 0, transform, 0, point, 0); |
| final float w = result[3]; |
| result[0] /= w; |
| result[1] /= w; |
| result[2] /= w; |
| result[3] = 0; |
| } |
| |
| private float min(float a, float b, float c, float d) { |
| return Math.min(a, Math.min(b, Math.min(c, d))); |
| } |
| |
| private float max(float a, float b, float c, float d) { |
| return Math.max(a, Math.max(b, Math.max(c, d))); |
| } |
| |
| private CharSequence getValue() { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { |
| return value; |
| } else { |
| return createSpannableString(value, valueAttributes); |
| } |
| } |
| |
| private CharSequence getLabel() { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { |
| return label; |
| } else { |
| return createSpannableString(label, labelAttributes); |
| } |
| } |
| |
| private CharSequence getHint() { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { |
| return hint; |
| } else { |
| return createSpannableString(hint, hintAttributes); |
| } |
| } |
| |
| private CharSequence getValueLabelHint() { |
| CharSequence[] array = new CharSequence[] {getValue(), getLabel(), getHint()}; |
| CharSequence result = null; |
| for (CharSequence word : array) { |
| if (word != null && word.length() > 0) { |
| if (result == null || result.length() == 0) { |
| result = word; |
| } else { |
| result = TextUtils.concat(result, ", ", word); |
| } |
| } |
| } |
| return result; |
| } |
| |
| private CharSequence getTextFieldHint() { |
| CharSequence[] array = new CharSequence[] {getLabel(), getHint()}; |
| CharSequence result = null; |
| for (CharSequence word : array) { |
| if (word != null && word.length() > 0) { |
| if (result == null || result.length() == 0) { |
| result = word; |
| } else { |
| result = TextUtils.concat(result, ", ", word); |
| } |
| } |
| } |
| return result; |
| } |
| |
| @TargetApi(21) |
| @RequiresApi(21) |
| private SpannableString createSpannableString(String string, List<StringAttribute> attributes) { |
| if (string == null) { |
| return null; |
| } |
| final SpannableString spannableString = new SpannableString(string); |
| if (attributes != null) { |
| for (StringAttribute attribute : attributes) { |
| switch (attribute.type) { |
| case SPELLOUT: |
| { |
| final TtsSpan ttsSpan = new TtsSpan.Builder<>(TtsSpan.TYPE_VERBATIM).build(); |
| spannableString.setSpan(ttsSpan, attribute.start, attribute.end, 0); |
| break; |
| } |
| case LOCALE: |
| { |
| LocaleStringAttribute localeAttribute = (LocaleStringAttribute) attribute; |
| Locale locale = Locale.forLanguageTag(localeAttribute.locale); |
| final LocaleSpan localeSpan = new LocaleSpan(locale); |
| spannableString.setSpan(localeSpan, attribute.start, attribute.end, 0); |
| break; |
| } |
| } |
| } |
| } |
| return spannableString; |
| } |
| } |
| |
| /** |
| * Delegates handling of {@link android.view.ViewParent#requestSendAccessibilityEvent} to the |
| * accessibility bridge. |
| * |
| * <p>This is used by embedded platform views to propagate accessibility events from their view |
| * hierarchy to the accessibility bridge. |
| * |
| * <p>As the embedded view doesn't have to be the only View in the embedded hierarchy (it can have |
| * child views) and the event might have been originated from any view in this hierarchy, this |
| * method gets both a reference to the embedded platform view, and a reference to the view from |
| * its hierarchy that sent the event. |
| * |
| * @param embeddedView the embedded platform view for which the event is delegated |
| * @param eventOrigin the view in the embedded view's hierarchy that sent the event. |
| * @return True if the event was sent. |
| */ |
| // AccessibilityEvent has many irrelevant cases that would be confusing to list. |
| @SuppressLint("SwitchIntDef") |
| public boolean externalViewRequestSendAccessibilityEvent( |
| View embeddedView, View eventOrigin, AccessibilityEvent event) { |
| if (!accessibilityViewEmbedder.requestSendAccessibilityEvent( |
| embeddedView, eventOrigin, event)) { |
| return false; |
| } |
| Integer virtualNodeId = accessibilityViewEmbedder.getRecordFlutterId(embeddedView, event); |
| if (virtualNodeId == null) { |
| return false; |
| } |
| switch (event.getEventType()) { |
| case AccessibilityEvent.TYPE_VIEW_HOVER_ENTER: |
| hoveredObject = null; |
| break; |
| case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: |
| embeddedAccessibilityFocusedNodeId = virtualNodeId; |
| accessibilityFocusedSemanticsNode = null; |
| break; |
| case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED: |
| embeddedInputFocusedNodeId = null; |
| embeddedAccessibilityFocusedNodeId = null; |
| break; |
| case AccessibilityEvent.TYPE_VIEW_FOCUSED: |
| embeddedInputFocusedNodeId = virtualNodeId; |
| inputFocusedSemanticsNode = null; |
| break; |
| } |
| return true; |
| } |
| } |