mirror of
https://github.com/encounter/engine.git
synced 2026-03-30 11:09:55 -07:00
47f4a2aad4
Not all platforms are expected to handle all SemanticsEvents. Therefore, it is ok to just drop unsupported events on the floor.
1077 lines
43 KiB
Java
1077 lines
43 KiB
Java
// 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);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|