| // Copyright 2013 The Chromium 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.graphics.Rect; |
| import android.opengl.Matrix; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.accessibility.AccessibilityEvent; |
| import android.view.accessibility.AccessibilityNodeInfo; |
| import android.view.accessibility.AccessibilityNodeProvider; |
| import io.flutter.plugin.common.BasicMessageChannel; |
| import io.flutter.plugin.common.StandardMessageCodec; |
| |
| import java.nio.ByteBuffer; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.Iterator; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| class AccessibilityBridge extends AccessibilityNodeProvider implements BasicMessageChannel.MessageHandler<Object> { |
| private static final String TAG = "FlutterView"; |
| |
| // 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 Map<Integer, SemanticsObject> mObjects; |
| private final FlutterView mOwner; |
| private boolean mAccessibilityEnabled = false; |
| private SemanticsObject mA11yFocusedObject; |
| private SemanticsObject mInputFocusedObject; |
| private SemanticsObject mHoveredObject; |
| private int previousRouteId = ROOT_NODE_ID; |
| private List<Integer> previousRoutes; |
| |
| private final BasicMessageChannel<Object> mFlutterAccessibilityChannel; |
| |
| 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); |
| |
| Action(int value) { |
| this.value = value; |
| } |
| |
| final int value; |
| } |
| |
| enum Flag { |
| HAS_CHECKED_STATE(1 << 0), |
| IS_CHECKED(1 << 1), |
| IS_SELECTED(1 << 2), |
| IS_BUTTON(1 << 3), |
| IS_TEXT_FIELD(1 << 4), |
| IS_FOCUSED(1 << 5), |
| HAS_ENABLED_STATE(1 << 6), |
| IS_ENABLED(1 << 7), |
| IS_IN_MUTUALLY_EXCLUSIVE_GROUP(1 << 8), |
| IS_HEADER(1 << 9), |
| IS_OBSCURED(1 << 10), |
| SCOPES_ROUTE(1 << 11), |
| NAMES_ROUTE(1 << 12), |
| IS_HIDDEN(1 << 13); |
| |
| Flag(int value) { |
| this.value = value; |
| } |
| |
| final int value; |
| } |
| |
| AccessibilityBridge(FlutterView owner) { |
| assert owner != null; |
| mOwner = owner; |
| mObjects = new HashMap<Integer, SemanticsObject>(); |
| previousRoutes = new ArrayList<>(); |
| mFlutterAccessibilityChannel = new BasicMessageChannel<>(owner, "flutter/accessibility", |
| StandardMessageCodec.INSTANCE); |
| } |
| |
| void setAccessibilityEnabled(boolean accessibilityEnabled) { |
| mAccessibilityEnabled = accessibilityEnabled; |
| if (accessibilityEnabled) { |
| mFlutterAccessibilityChannel.setMessageHandler(this); |
| } else { |
| mFlutterAccessibilityChannel.setMessageHandler(null); |
| } |
| } |
| |
| @Override |
| @SuppressWarnings("deprecation") |
| public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { |
| if (virtualViewId == View.NO_ID) { |
| AccessibilityNodeInfo result = AccessibilityNodeInfo.obtain(mOwner); |
| mOwner.onInitializeAccessibilityNodeInfo(result); |
| if (mObjects.containsKey(ROOT_NODE_ID)) |
| result.addChild(mOwner, ROOT_NODE_ID); |
| return result; |
| } |
| |
| SemanticsObject object = mObjects.get(virtualViewId); |
| if (object == null) |
| return null; |
| |
| AccessibilityNodeInfo result = AccessibilityNodeInfo.obtain(mOwner, virtualViewId); |
| result.setPackageName(mOwner.getContext().getPackageName()); |
| result.setClassName("android.view.View"); |
| result.setSource(mOwner, virtualViewId); |
| result.setFocusable(object.isFocusable()); |
| if (mInputFocusedObject != null) |
| result.setFocused(mInputFocusedObject.id == virtualViewId); |
| |
| if (mA11yFocusedObject != null) |
| result.setAccessibilityFocused(mA11yFocusedObject.id == virtualViewId); |
| |
| if (object.hasFlag(Flag.IS_TEXT_FIELD)) { |
| result.setPassword(object.hasFlag(Flag.IS_OBSCURED)); |
| result.setClassName("android.widget.EditText"); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { |
| result.setEditable(true); |
| if (object.textSelectionBase != -1 && object.textSelectionExtent != -1) { |
| result.setTextSelection(object.textSelectionBase, object.textSelectionExtent); |
| } |
| } |
| |
| // Cursor movements |
| int granularities = 0; |
| if (object.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); |
| granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER; |
| } |
| if (object.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); |
| granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER; |
| } |
| result.setMovementGranularities(granularities); |
| } |
| if (object.hasAction(Action.SET_SELECTION)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION); |
| } |
| if (object.hasAction(Action.COPY)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_COPY); |
| } |
| if (object.hasAction(Action.CUT)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_CUT); |
| } |
| if (object.hasAction(Action.PASTE)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_PASTE); |
| } |
| |
| if (object.hasFlag(Flag.IS_BUTTON)) { |
| result.setClassName("android.widget.Button"); |
| } |
| |
| if (object.parent != null) { |
| assert object.id > ROOT_NODE_ID; |
| result.setParent(mOwner, object.parent.id); |
| } else { |
| assert object.id == ROOT_NODE_ID; |
| result.setParent(mOwner); |
| } |
| |
| Rect bounds = object.getGlobalRect(); |
| if (object.parent != null) { |
| Rect parentBounds = object.parent.getGlobalRect(); |
| Rect boundsInParent = new Rect(bounds); |
| boundsInParent.offset(-parentBounds.left, -parentBounds.top); |
| result.setBoundsInParent(boundsInParent); |
| } else { |
| result.setBoundsInParent(bounds); |
| } |
| result.setBoundsInScreen(bounds); |
| result.setVisibleToUser(true); |
| result.setEnabled(!object.hasFlag(Flag.HAS_ENABLED_STATE) || |
| object.hasFlag(Flag.IS_ENABLED)); |
| |
| if (object.hasAction(Action.TAP)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_CLICK); |
| result.setClickable(true); |
| } |
| if (object.hasAction(Action.LONG_PRESS)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_LONG_CLICK); |
| result.setLongClickable(true); |
| } |
| if (object.hasAction(Action.SCROLL_LEFT) || object.hasAction(Action.SCROLL_UP) |
| || object.hasAction(Action.SCROLL_RIGHT) || object.hasAction(Action.SCROLL_DOWN)) { |
| result.setScrollable(true); |
| // This tells Android's a11y to send scroll events when reaching the end of |
| // the visible viewport of a scrollable. |
| 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 (object.hasAction(Action.SCROLL_LEFT) || object.hasAction(Action.SCROLL_UP)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); |
| } |
| if (object.hasAction(Action.SCROLL_RIGHT) || object.hasAction(Action.SCROLL_DOWN)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); |
| } |
| } |
| if (object.hasAction(Action.INCREASE) || object.hasAction(Action.DECREASE)) { |
| result.setClassName("android.widget.SeekBar"); |
| if (object.hasAction(Action.INCREASE)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); |
| } |
| if (object.hasAction(Action.DECREASE)) { |
| result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); |
| } |
| } |
| |
| boolean hasCheckedState = object.hasFlag(Flag.HAS_CHECKED_STATE); |
| result.setCheckable(hasCheckedState); |
| if (hasCheckedState) { |
| result.setChecked(object.hasFlag(Flag.IS_CHECKED)); |
| if (object.hasFlag(Flag.IS_IN_MUTUALLY_EXCLUSIVE_GROUP)) |
| result.setClassName("android.widget.RadioButton"); |
| else |
| result.setClassName("android.widget.CheckBox"); |
| } |
| |
| result.setSelected(object.hasFlag(Flag.IS_SELECTED)); |
| result.setText(object.getValueLabelHint()); |
| |
| // Accessibility Focus |
| if (mA11yFocusedObject != null && mA11yFocusedObject.id == virtualViewId) { |
| result.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); |
| } else { |
| result.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); |
| } |
| |
| if (object.childrenInTraversalOrder != null) { |
| for (SemanticsObject child : object.childrenInTraversalOrder) { |
| if (!child.hasFlag(Flag.IS_HIDDEN)) { |
| result.addChild(mOwner, child.id); |
| } |
| } |
| } |
| |
| return result; |
| } |
| |
| @Override |
| public boolean performAction(int virtualViewId, int action, Bundle arguments) { |
| SemanticsObject object = mObjects.get(virtualViewId); |
| if (object == null) { |
| return false; |
| } |
| switch (action) { |
| 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. |
| mOwner.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. |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.LONG_PRESS); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: { |
| if (object.hasAction(Action.SCROLL_UP)) { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_UP); |
| } else if (object.hasAction(Action.SCROLL_LEFT)) { |
| // TODO(ianh): bidi support using textDirection |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_LEFT); |
| } else if (object.hasAction(Action.INCREASE)) { |
| object.value = object.increasedValue; |
| // Event causes Android to read out the updated value. |
| sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.INCREASE); |
| } else { |
| return false; |
| } |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: { |
| if (object.hasAction(Action.SCROLL_DOWN)) { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_DOWN); |
| } else if (object.hasAction(Action.SCROLL_RIGHT)) { |
| // TODO(ianh): bidi support using textDirection |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.SCROLL_RIGHT); |
| } else if (object.hasAction(Action.DECREASE)) { |
| object.value = object.decreasedValue; |
| // Event causes Android to read out the updated value. |
| sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.DECREASE); |
| } else { |
| return false; |
| } |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: { |
| return performCursorMoveAction(object, virtualViewId, arguments, false); |
| } |
| case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: { |
| return performCursorMoveAction(object, virtualViewId, arguments, true); |
| } |
| case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS: { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.DID_LOSE_ACCESSIBILITY_FOCUS); |
| sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); |
| mA11yFocusedObject = null; |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.DID_GAIN_ACCESSIBILITY_FOCUS); |
| sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); |
| |
| if (mA11yFocusedObject == 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.) |
| mOwner.invalidate(); |
| } |
| mA11yFocusedObject = object; |
| |
| if (object.hasAction(Action.INCREASE) || object.hasAction(Action.DECREASE)) { |
| // SeekBars only announce themselves after this event. |
| sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); |
| } |
| |
| return true; |
| } |
| case ACTION_SHOW_ON_SCREEN: { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.SHOW_ON_SCREEN); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_SET_SELECTION: { |
| final Map<String, Integer> selection = new HashMap<String, Integer>(); |
| 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", object.textSelectionExtent); |
| selection.put("extent", object.textSelectionExtent); |
| } |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.SET_SELECTION, selection); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_COPY: { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.COPY); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_CUT: { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.CUT); |
| return true; |
| } |
| case AccessibilityNodeInfo.ACTION_PASTE: { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.PASTE); |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| boolean performCursorMoveAction(SemanticsObject object, int virtualViewId, Bundle arguments, boolean forward) { |
| final int granularity = arguments.getInt( |
| AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); |
| final boolean extendSelection = arguments.getBoolean( |
| AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN); |
| switch (granularity) { |
| case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: { |
| if (forward && object.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.MOVE_CURSOR_FORWARD_BY_CHARACTER, extendSelection); |
| return true; |
| } |
| if (!forward && object.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) { |
| mOwner.dispatchSemanticsAction(virtualViewId, Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER, extendSelection); |
| return true; |
| } |
| } |
| // TODO(goderbauer): support other granularities. |
| } |
| return false; |
| } |
| |
| // TODO(ianh): implement findAccessibilityNodeInfosByText() |
| |
| @Override |
| public AccessibilityNodeInfo findFocus(int focus) { |
| switch (focus) { |
| case AccessibilityNodeInfo.FOCUS_INPUT: { |
| if (mInputFocusedObject != null) |
| return createAccessibilityNodeInfo(mInputFocusedObject.id); |
| } |
| // Fall through to check FOCUS_ACCESSIBILITY |
| case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY: { |
| if (mA11yFocusedObject != null) |
| return createAccessibilityNodeInfo(mA11yFocusedObject.id); |
| } |
| } |
| return null; |
| } |
| |
| |
| private SemanticsObject getRootObject() { |
| assert mObjects.containsKey(0); |
| return mObjects.get(0); |
| } |
| |
| private SemanticsObject getOrCreateObject(int id) { |
| SemanticsObject object = mObjects.get(id); |
| if (object == null) { |
| object = new SemanticsObject(); |
| object.id = id; |
| mObjects.put(id, object); |
| } |
| return object; |
| } |
| |
| void handleTouchExplorationExit() { |
| if (mHoveredObject != null) { |
| sendAccessibilityEvent(mHoveredObject.id, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); |
| mHoveredObject = null; |
| } |
| } |
| |
| void handleTouchExploration(float x, float y) { |
| if (mObjects.isEmpty()) { |
| return; |
| } |
| SemanticsObject newObject = getRootObject().hitTest(new float[]{ x, y, 0, 1 }); |
| if (newObject != mHoveredObject) { |
| // sending ENTER before EXIT is how Android wants it |
| if (newObject != null) { |
| sendAccessibilityEvent(newObject.id, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER); |
| } |
| if (mHoveredObject != null) { |
| sendAccessibilityEvent(mHoveredObject.id, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); |
| } |
| mHoveredObject = newObject; |
| } |
| } |
| |
| void updateSemantics(ByteBuffer buffer, String[] strings) { |
| ArrayList<SemanticsObject> updated = new ArrayList<SemanticsObject>(); |
| while (buffer.hasRemaining()) { |
| int id = buffer.getInt(); |
| SemanticsObject object = getOrCreateObject(id); |
| object.updateWith(buffer, strings); |
| if (object.hasFlag(Flag.IS_HIDDEN)) { |
| continue; |
| } |
| if (object.hasFlag(Flag.IS_FOCUSED)) { |
| mInputFocusedObject = object; |
| } |
| if (object.hadPreviousConfig) { |
| updated.add(object); |
| } |
| } |
| |
| Set<SemanticsObject> visitedObjects = new HashSet<SemanticsObject>(); |
| SemanticsObject rootObject = getRootObject(); |
| List<SemanticsObject> newRoutes = new ArrayList<>(); |
| if (rootObject != null) { |
| final float[] identity = new float[16]; |
| Matrix.setIdentityM(identity, 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. |
| SemanticsObject lastAdded = null; |
| for (SemanticsObject semanticsObject : newRoutes) { |
| if (!previousRoutes.contains(semanticsObject.id)) { |
| lastAdded = semanticsObject; |
| } |
| } |
| if (lastAdded == null && newRoutes.size() > 0) { |
| lastAdded = newRoutes.get(newRoutes.size() - 1); |
| } |
| if (lastAdded != null && lastAdded.id != previousRouteId) { |
| previousRouteId = lastAdded.id; |
| createWindowChangeEvent(lastAdded); |
| } |
| previousRoutes.clear(); |
| for (SemanticsObject semanticsObject : newRoutes) { |
| previousRoutes.add(semanticsObject.id); |
| } |
| |
| Iterator<Map.Entry<Integer, SemanticsObject>> it = mObjects.entrySet().iterator(); |
| while (it.hasNext()) { |
| Map.Entry<Integer, SemanticsObject> entry = it.next(); |
| SemanticsObject object = entry.getValue(); |
| if (!visitedObjects.contains(object)) { |
| willRemoveSemanticsObject(object); |
| it.remove(); |
| } |
| } |
| |
| // TODO(goderbauer): Send this event only once (!) for changed subtrees, |
| // see https://github.com/flutter/flutter/issues/14534 |
| sendAccessibilityEvent(0, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); |
| |
| for (SemanticsObject 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); |
| } |
| sendAccessibilityEvent(event); |
| } |
| if (mA11yFocusedObject != null && mA11yFocusedObject.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 (mInputFocusedObject != null && mInputFocusedObject.id == object.id |
| && object.hadFlag(Flag.IS_TEXT_FIELD) |
| && object.hasFlag(Flag.IS_TEXT_FIELD)) { |
| 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; |
| } |
| |
| private AccessibilityEvent obtainAccessibilityEvent(int virtualViewId, int eventType) { |
| assert virtualViewId != ROOT_NODE_ID; |
| AccessibilityEvent event = AccessibilityEvent.obtain(eventType); |
| event.setPackageName(mOwner.getContext().getPackageName()); |
| event.setSource(mOwner, virtualViewId); |
| return event; |
| } |
| |
| private void sendAccessibilityEvent(int virtualViewId, int eventType) { |
| if (!mAccessibilityEnabled) { |
| return; |
| } |
| if (virtualViewId == ROOT_NODE_ID) { |
| mOwner.sendAccessibilityEvent(eventType); |
| } else { |
| sendAccessibilityEvent(obtainAccessibilityEvent(virtualViewId, eventType)); |
| } |
| } |
| |
| private void sendAccessibilityEvent(AccessibilityEvent event) { |
| if (!mAccessibilityEnabled) { |
| return; |
| } |
| mOwner.getParent().requestSendAccessibilityEvent(mOwner, event); |
| } |
| |
| // Message Handler for [mFlutterAccessibilityChannel]. |
| public void onMessage(Object message, BasicMessageChannel.Reply<Object> reply) { |
| @SuppressWarnings("unchecked") |
| final HashMap<String, Object> annotatedEvent = (HashMap<String, Object>)message; |
| final String type = (String)annotatedEvent.get("type"); |
| @SuppressWarnings("unchecked") |
| final HashMap<String, Object> data = (HashMap<String, Object>)annotatedEvent.get("data"); |
| |
| switch (type) { |
| case "announce": |
| mOwner.announceForAccessibility((String) data.get("message")); |
| break; |
| case "longPress": { |
| Integer nodeId = (Integer) annotatedEvent.get("nodeId"); |
| if (nodeId == null) { |
| return; |
| } |
| sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_LONG_CLICKED); |
| break; |
| } |
| case "tap": { |
| Integer nodeId = (Integer) annotatedEvent.get("nodeId"); |
| if (nodeId == null) { |
| return; |
| } |
| sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_CLICKED); |
| break; |
| } |
| case "tooltip": { |
| AccessibilityEvent e = obtainAccessibilityEvent(ROOT_NODE_ID, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); |
| e.getText().add((String) data.get("message")); |
| sendAccessibilityEvent(e); |
| } |
| } |
| } |
| |
| private void createWindowChangeEvent(SemanticsObject route) { |
| AccessibilityEvent e = obtainAccessibilityEvent(route.id, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); |
| String routeName = route.getRouteName(); |
| e.getText().add(routeName); |
| sendAccessibilityEvent(e); |
| } |
| |
| private void willRemoveSemanticsObject(SemanticsObject object) { |
| assert mObjects.containsKey(object.id); |
| assert mObjects.get(object.id) == object; |
| object.parent = null; |
| if (mA11yFocusedObject == object) { |
| sendAccessibilityEvent(mA11yFocusedObject.id, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); |
| mA11yFocusedObject = null; |
| } |
| if (mInputFocusedObject == object) { |
| mInputFocusedObject = null; |
| } |
| if (mHoveredObject == object) { |
| mHoveredObject = null; |
| } |
| } |
| |
| void reset() { |
| mObjects.clear(); |
| if (mA11yFocusedObject != null) |
| sendAccessibilityEvent(mA11yFocusedObject.id, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); |
| mA11yFocusedObject = null; |
| mHoveredObject = null; |
| sendAccessibilityEvent(0, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); |
| } |
| |
| private enum TextDirection { |
| UNKNOWN, LTR, RTL; |
| |
| public static TextDirection fromInt(int value) { |
| switch (value) { |
| case 1: |
| return RTL; |
| case 2: |
| return LTR; |
| } |
| return UNKNOWN; |
| } |
| } |
| |
| private class SemanticsObject { |
| SemanticsObject() { } |
| |
| int id = -1; |
| |
| int flags; |
| int actions; |
| int textSelectionBase; |
| int textSelectionExtent; |
| float scrollPosition; |
| float scrollExtentMax; |
| float scrollExtentMin; |
| String label; |
| String value; |
| String increasedValue; |
| String decreasedValue; |
| String hint; |
| TextDirection textDirection; |
| |
| boolean hadPreviousConfig = false; |
| int previousFlags; |
| int previousActions; |
| int previousTextSelectionBase; |
| int previousTextSelectionExtent; |
| float previousScrollPosition; |
| float previousScrollExtentMax; |
| float previousScrollExtentMin; |
| String previousValue; |
| |
| private float left; |
| private float top; |
| private float right; |
| private float bottom; |
| private float[] transform; |
| |
| SemanticsObject parent; |
| List<SemanticsObject> childrenInTraversalOrder; |
| List<SemanticsObject> childrenInHitTestOrder; |
| |
| private boolean inverseTransformDirty = true; |
| private float[] inverseTransform; |
| |
| private boolean globalGeometryDirty = true; |
| private float[] globalTransform; |
| private Rect globalRect; |
| |
| boolean hasAction(Action action) { |
| return (actions & action.value) != 0; |
| } |
| |
| boolean hadAction(Action action) { |
| return (previousActions & action.value) != 0; |
| } |
| |
| boolean hasFlag(Flag flag) { |
| return (flags & flag.value) != 0; |
| } |
| |
| boolean hadFlag(Flag flag) { |
| assert hadPreviousConfig; |
| return (previousFlags & flag.value) != 0; |
| } |
| |
| boolean didScroll() { |
| return !Float.isNaN(scrollPosition) && !Float.isNaN(previousScrollPosition) |
| && previousScrollPosition != scrollPosition; |
| } |
| |
| void log(String indent, boolean recursive) { |
| Log.i(TAG, indent + "SemanticsObject id=" + id + " label=" + label + " actions=" + actions + " flags=" + flags + "\n" + |
| indent + " +-- textDirection=" + textDirection + "\n"+ |
| indent + " +-- rect.ltrb=(" + left + ", " + top + ", " + right + ", " + bottom + ")\n" + |
| indent + " +-- transform=" + Arrays.toString(transform) + "\n"); |
| if (childrenInTraversalOrder != null && recursive) { |
| String childIndent = indent + " "; |
| for (SemanticsObject child : childrenInTraversalOrder) { |
| child.log(childIndent, recursive); |
| } |
| } |
| } |
| |
| void updateWith(ByteBuffer buffer, String[] strings) { |
| hadPreviousConfig = true; |
| previousValue = value; |
| previousFlags = flags; |
| previousActions = actions; |
| previousTextSelectionBase = textSelectionBase; |
| previousTextSelectionExtent = textSelectionExtent; |
| previousScrollPosition = scrollPosition; |
| previousScrollExtentMax = scrollExtentMax; |
| previousScrollExtentMin = scrollExtentMin; |
| |
| flags = buffer.getInt(); |
| actions = buffer.getInt(); |
| textSelectionBase = buffer.getInt(); |
| textSelectionExtent = buffer.getInt(); |
| scrollPosition = buffer.getFloat(); |
| scrollExtentMax = buffer.getFloat(); |
| scrollExtentMin = buffer.getFloat(); |
| |
| int stringIndex = buffer.getInt(); |
| label = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| stringIndex = buffer.getInt(); |
| value = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| stringIndex = buffer.getInt(); |
| increasedValue = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| stringIndex = buffer.getInt(); |
| decreasedValue = stringIndex == -1 ? null : strings[stringIndex]; |
| |
| stringIndex = buffer.getInt(); |
| hint = 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(); |
| if (childCount == 0) { |
| childrenInTraversalOrder = null; |
| childrenInHitTestOrder = null; |
| } else { |
| if (childrenInTraversalOrder == null) |
| childrenInTraversalOrder = new ArrayList<SemanticsObject>(childCount); |
| else |
| childrenInTraversalOrder.clear(); |
| |
| for (int i = 0; i < childCount; ++i) { |
| SemanticsObject child = getOrCreateObject(buffer.getInt()); |
| child.parent = this; |
| childrenInTraversalOrder.add(child); |
| } |
| |
| if (childrenInHitTestOrder == null) |
| childrenInHitTestOrder = new ArrayList<SemanticsObject>(childCount); |
| else |
| childrenInHitTestOrder.clear(); |
| |
| for (int i = 0; i < childCount; ++i) { |
| SemanticsObject child = getOrCreateObject(buffer.getInt()); |
| child.parent = this; |
| childrenInHitTestOrder.add(child); |
| } |
| } |
| } |
| |
| 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); |
| } |
| |
| Rect getGlobalRect() { |
| assert !globalGeometryDirty; |
| return globalRect; |
| } |
| |
| SemanticsObject hitTest(float[] point) { |
| 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; |
| if (childrenInHitTestOrder != null) { |
| final float[] transformedPoint = new float[4]; |
| for (int i = 0; i < childrenInHitTestOrder.size(); i += 1) { |
| final SemanticsObject child = childrenInHitTestOrder.get(i); |
| if (child.hasFlag(Flag.IS_HIDDEN)) { |
| continue; |
| } |
| child.ensureInverseTransform(); |
| Matrix.multiplyMV(transformedPoint, 0, child.inverseTransform, 0, point, 0); |
| final SemanticsObject result = child.hitTest(transformedPoint); |
| if (result != null) { |
| return result; |
| } |
| } |
| } |
| return this; |
| } |
| |
| // TODO(goderbauer): This should be decided by the framework once we have more information |
| // about focusability there. |
| boolean isFocusable() { |
| // We enforce in the framework that no other useful semantics are merged with these |
| // nodes. |
| if (hasFlag(Flag.SCOPES_ROUTE)) { |
| return false; |
| } |
| int scrollableActions = Action.SCROLL_RIGHT.value | Action.SCROLL_LEFT.value |
| | Action.SCROLL_UP.value | Action.SCROLL_DOWN.value; |
| return (actions & ~scrollableActions) != 0 |
| || flags != 0 |
| || (label != null && !label.isEmpty()) |
| || (value != null && !value.isEmpty()) |
| || (hint != null && !hint.isEmpty()); |
| } |
| |
| void collectRoutes(List<SemanticsObject> edges) { |
| if (hasFlag(Flag.SCOPES_ROUTE)) { |
| edges.add(this); |
| } |
| if (childrenInTraversalOrder != null) { |
| for (int i = 0; i < childrenInTraversalOrder.size(); ++i) { |
| childrenInTraversalOrder.get(i).collectRoutes(edges); |
| } |
| } |
| } |
| |
| 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; |
| } |
| } |
| if (childrenInTraversalOrder != null) { |
| for (int i = 0; i < childrenInTraversalOrder.size(); ++i) { |
| String newName = childrenInTraversalOrder.get(i).getRouteName(); |
| if (newName != null && !newName.isEmpty()) { |
| return newName; |
| } |
| } |
| } |
| return null; |
| } |
| |
| void updateRecursively(float[] ancestorTransform, Set<SemanticsObject> visitedObjects, boolean forceUpdate) { |
| visitedObjects.add(this); |
| |
| if (globalGeometryDirty) |
| forceUpdate = true; |
| |
| if (forceUpdate) { |
| if (globalTransform == null) |
| globalTransform = 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; |
| } |
| |
| assert globalTransform != null; |
| assert globalRect != null; |
| |
| if (childrenInTraversalOrder != null) { |
| for (int i = 0; i < childrenInTraversalOrder.size(); ++i) { |
| childrenInTraversalOrder.get(i).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 String getValueLabelHint() { |
| StringBuilder sb = new StringBuilder(); |
| String[] array = { value, label, hint }; |
| for (String word: array) { |
| if (word != null && word.length() > 0) { |
| if (sb.length() > 0) |
| sb.append(", "); |
| sb.append(word); |
| } |
| } |
| return sb.length() > 0 ? sb.toString() : null; |
| } |
| } |
| } |