// 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.graphics.Rect; import android.os.Build; import android.os.Bundle; import android.os.Parcel; import android.util.Log; import android.util.SparseArray; import android.view.MotionEvent; import android.view.View; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import android.view.accessibility.AccessibilityNodeProvider; import android.view.accessibility.AccessibilityRecord; import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; /** * Facilitates embedding of platform views in the accessibility tree generated by the accessibility * bridge. * *
Embedding is done by mirroring the accessibility tree of the platform view as a subtree of the * flutter accessibility tree. * *
This class relies on hidden system APIs to extract the accessibility information and does not * work starting Android P; If the reflection accessors are not available we fail silently by * embedding a null node, the app continues working but the accessibility information for the * platform view will not be embedded. * *
We use the term `flutterId` for virtual accessibility node IDs in the FlutterView tree, and
* the term `originId` for the virtual accessibility node IDs in the platform view's tree.
* Internally this class maintains a bidirectional mapping between `flutterId`s and the
* corresponding platform view and `originId`.
*/
@Keep
class AccessibilityViewEmbedder {
private static final String TAG = "AccessibilityBridge";
private final ReflectionAccessors reflectionAccessors;
// The view to which the platform view is embedded, this is typically FlutterView.
private final View rootAccessibilityView;
// Maps a flutterId to the corresponding platform view and originId.
private final SparseArray The pointer coordinates are translated to the embedded view's coordinate system.
*/
public boolean onAccessibilityHoverEvent(int rootFlutterId, @NonNull MotionEvent event) {
ViewAndId origin = flutterIdToOrigin.get(rootFlutterId);
if (origin == null) {
return false;
}
Rect displayBounds = embeddedViewToDisplayBounds.get(origin.view);
int pointerCount = event.getPointerCount();
MotionEvent.PointerProperties[] pointerProperties =
new MotionEvent.PointerProperties[pointerCount];
MotionEvent.PointerCoords[] pointerCoords = new MotionEvent.PointerCoords[pointerCount];
for (int i = 0; i < event.getPointerCount(); i++) {
pointerProperties[i] = new MotionEvent.PointerProperties();
event.getPointerProperties(i, pointerProperties[i]);
MotionEvent.PointerCoords originCoords = new MotionEvent.PointerCoords();
event.getPointerCoords(i, originCoords);
pointerCoords[i] = new MotionEvent.PointerCoords(originCoords);
pointerCoords[i].x -= displayBounds.left;
pointerCoords[i].y -= displayBounds.top;
}
MotionEvent translatedEvent =
MotionEvent.obtain(
event.getDownTime(),
event.getEventTime(),
event.getAction(),
event.getPointerCount(),
pointerProperties,
pointerCoords,
event.getMetaState(),
event.getButtonState(),
event.getXPrecision(),
event.getYPrecision(),
event.getDeviceId(),
event.getEdgeFlags(),
event.getSource(),
event.getFlags());
return origin.view.dispatchGenericMotionEvent(translatedEvent);
}
/**
* Returns the View that contains the accessibility node identified by the provided flutterId or
* null if it doesn't belong to a view.
*/
public View platformViewOfNode(int flutterId) {
ViewAndId viewAndId = flutterIdToOrigin.get(flutterId);
if (viewAndId == null) {
return null;
}
return viewAndId.view;
}
private static class ViewAndId {
final View view;
final int id;
private ViewAndId(View view, int id) {
this.view = view;
this.id = id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ViewAndId)) return false;
ViewAndId viewAndId = (ViewAndId) o;
return id == viewAndId.id && view.equals(viewAndId.view);
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + view.hashCode();
result = prime * result + id;
return result;
}
}
private static class ReflectionAccessors {
private @Nullable final Method getSourceNodeId;
private @Nullable final Method getParentNodeId;
private @Nullable final Method getRecordSourceNodeId;
private @Nullable final Method getChildId;
private @Nullable final Field childNodeIdsField;
private @Nullable final Method longArrayGetIndex;
@SuppressLint("PrivateApi")
private ReflectionAccessors() {
Method getSourceNodeId = null;
Method getParentNodeId = null;
Method getRecordSourceNodeId = null;
Method getChildId = null;
Field childNodeIdsField = null;
Method longArrayGetIndex = null;
try {
getSourceNodeId = AccessibilityNodeInfo.class.getMethod("getSourceNodeId");
} catch (NoSuchMethodException e) {
Log.w(TAG, "can't invoke AccessibilityNodeInfo#getSourceNodeId with reflection");
}
try {
getRecordSourceNodeId = AccessibilityRecord.class.getMethod("getSourceNodeId");
} catch (NoSuchMethodException e) {
Log.w(TAG, "can't invoke AccessibiiltyRecord#getSourceNodeId with reflection");
}
// Reflection access is not allowed starting Android P on these methods.
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) {
try {
getParentNodeId = AccessibilityNodeInfo.class.getMethod("getParentNodeId");
} catch (NoSuchMethodException e) {
Log.w(TAG, "can't invoke getParentNodeId with reflection");
}
// Starting P we extract the child id from the mChildNodeIds field (see getChildId
// below).
try {
getChildId = AccessibilityNodeInfo.class.getMethod("getChildId", int.class);
} catch (NoSuchMethodException e) {
Log.w(TAG, "can't invoke getChildId with reflection");
}
} else {
try {
childNodeIdsField = AccessibilityNodeInfo.class.getDeclaredField("mChildNodeIds");
childNodeIdsField.setAccessible(true);
// The private member is a private utility class to Android. We need to use
// reflection to actually handle the data too.
longArrayGetIndex = Class.forName("android.util.LongArray").getMethod("get", int.class);
} catch (NoSuchFieldException
| ClassNotFoundException
| NoSuchMethodException
| NullPointerException e) {
Log.w(TAG, "can't access childNodeIdsField with reflection");
childNodeIdsField = null;
}
}
this.getSourceNodeId = getSourceNodeId;
this.getParentNodeId = getParentNodeId;
this.getRecordSourceNodeId = getRecordSourceNodeId;
this.getChildId = getChildId;
this.childNodeIdsField = childNodeIdsField;
this.longArrayGetIndex = longArrayGetIndex;
}
/** Returns virtual node ID given packed node ID used internally in accessibility API. */
private static int getVirtualNodeId(long nodeId) {
return (int) (nodeId >> 32);
}
@Nullable
private Long getSourceNodeId(@NonNull AccessibilityNodeInfo node) {
if (getSourceNodeId == null) {
return null;
}
try {
return (Long) getSourceNodeId.invoke(node);
} catch (IllegalAccessException e) {
Log.w(TAG, e);
} catch (InvocationTargetException e) {
Log.w(TAG, e);
}
return null;
}
@Nullable
private Long getChildId(@NonNull AccessibilityNodeInfo node, int child) {
if (getChildId == null && (childNodeIdsField == null || longArrayGetIndex == null)) {
return null;
}
if (getChildId != null) {
try {
return (Long) getChildId.invoke(node, child);
// Using identical separate catch blocks to comply with the following lint:
// Error: Multi-catch with these reflection exceptions requires API level 19
// (current min is 16) because they get compiled to the common but new super
// type ReflectiveOperationException. As a workaround either create individual
// catch statements, or catch Exception. [NewApi]
} catch (IllegalAccessException e) {
Log.w(TAG, e);
} catch (InvocationTargetException e) {
Log.w(TAG, e);
}
} else {
try {
return (long) longArrayGetIndex.invoke(childNodeIdsField.get(node), child);
// Using identical separate catch blocks to comply with the following lint:
// Error: Multi-catch with these reflection exceptions requires API level 19
// (current min is 16) because they get compiled to the common but new super
// type ReflectiveOperationException. As a workaround either create individual
// catch statements, or catch Exception. [NewApi]
} catch (IllegalAccessException e) {
Log.w(TAG, e);
} catch (InvocationTargetException | ArrayIndexOutOfBoundsException e) {
Log.w(TAG, e);
}
}
return null;
}
@Nullable
private Long getParentNodeId(@NonNull AccessibilityNodeInfo node) {
if (getParentNodeId != null) {
try {
return (long) getParentNodeId.invoke(node);
// Using identical separate catch blocks to comply with the following lint:
// Error: Multi-catch with these reflection exceptions requires API level 19
// (current min is 16) because they get compiled to the common but new super
// type ReflectiveOperationException. As a workaround either create individual
// catch statements, or catch Exception. [NewApi]
} catch (IllegalAccessException e) {
Log.w(TAG, e);
} catch (InvocationTargetException e) {
Log.w(TAG, e);
}
}
// Fall back on reading the ID from a serialized data if we absolutely have to.
return yoinkParentIdFromParcel(node);
}
// If this looks like it's failing, that's because it probably is. This method is relying on
// the implementation details of `AccessibilityNodeInfo#writeToParcel` in order to find the
// particular bit in the opaque parcel that represents mParentNodeId. If the implementation
// details change from our assumptions in this method, this will silently break.
@Nullable
private static Long yoinkParentIdFromParcel(AccessibilityNodeInfo node) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Log.w(TAG, "Unexpected Android version. Unable to find the parent ID.");
return null;
}
// We're creating a copy here because writing a node to a parcel recycles it. Objects
// are passed by reference in Java. So even though this method doesn't seem to use the
// node again, it's really used in other methods that would throw exceptions if we
// recycle it here.
AccessibilityNodeInfo copy = AccessibilityNodeInfo.obtain(node);
final Parcel parcel = Parcel.obtain();
parcel.setDataPosition(0);
copy.writeToParcel(parcel, /*flags=*/ 0);
Long parentNodeId = null;
// Match the internal logic that sets where mParentId actually ends up finally living.
// This logic should match
// https://android.googlesource.com/platform/frameworks/base/+/0b5ca24a4/core/java/android/view/accessibility/AccessibilityNodeInfo.java#3524.
parcel.setDataPosition(0);
long nonDefaultFields = parcel.readLong();
int fieldIndex = 0;
if (isBitSet(nonDefaultFields, fieldIndex++)) {
parcel.readInt(); // mIsSealed
}
if (isBitSet(nonDefaultFields, fieldIndex++)) {
parcel.readLong(); // mSourceNodeId
}
if (isBitSet(nonDefaultFields, fieldIndex++)) {
parcel.readInt(); // mWindowId
}
if (isBitSet(nonDefaultFields, fieldIndex++)) {
parentNodeId = parcel.readLong();
}
parcel.recycle();
return parentNodeId;
}
private static boolean isBitSet(long flags, int bitIndex) {
return (flags & (1L << bitIndex)) != 0;
}
@Nullable
private Long getRecordSourceNodeId(@NonNull AccessibilityRecord node) {
if (getRecordSourceNodeId == null) {
return null;
}
try {
return (Long) getRecordSourceNodeId.invoke(node);
} catch (IllegalAccessException e) {
Log.w(TAG, e);
} catch (InvocationTargetException e) {
Log.w(TAG, e);
}
return null;
}
}
}