From 6d5aaa090abe19ff0f08eb28f6663173750fd80c Mon Sep 17 00:00:00 2001 From: Michael Klimushyn Date: Fri, 14 Jun 2019 11:43:41 -0700 Subject: [PATCH] Fix a11y in embedded Android views post O (#9321) This patch works around Android's limitations on reflection. With it embedded platform views that use virtual node hierarchy trees should be accessible on all Android versions to date. The workarounds in this PR are brittle. Ideally the methods would be made public in Android instead so we wouldn't need to employ tactics like these to work around the missing methods. `AccessibilityNodeInfo#getChildId` is blocked from any type of reflection access, but the underlying private member that the getter accesses, `mChildNodeIds`, can still be reflected on. On Android versions where we can't access the getter, this patch falls back on reflectively accessing the field instead. Unfortunately this field is a [`LongArray`](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/util/LongArray.java), a utility data class private to Android. So in this case we're reflecting to both access the member and actually read data from it, since we need to use reflection to call `LongArray.get(index)`. `AccessibilityNodeInfo#getParent()` doesn't have any lucky available underlying members. However, `AccessibilityNodeInfo` itself is `Parcelable`, and `mParentNodeId` is one of the pieces of data that's written to a parcel via `AccessibilityNodeInfo#writeToParcel`. So the fallback for that is to write the node to a parcel and then read the parcel for the ID in question. This will break if the implementation details of `AccessibilityNodeInfo#writeToParcel` ever change. The details have already changed enough in the past to require two sets of logic for reading from the parcel. --- .../view/AccessibilityViewEmbedder.java | 157 ++++++++++++++---- 1 file changed, 127 insertions(+), 30 deletions(-) diff --git a/shell/platform/android/io/flutter/view/AccessibilityViewEmbedder.java b/shell/platform/android/io/flutter/view/AccessibilityViewEmbedder.java index f4858d75174..4fcea1b13bc 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityViewEmbedder.java +++ b/shell/platform/android/io/flutter/view/AccessibilityViewEmbedder.java @@ -4,9 +4,12 @@ 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.os.Parcelable; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Log; @@ -19,6 +22,7 @@ import android.view.accessibility.AccessibilityNodeProvider; import android.view.accessibility.AccessibilityRecord; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; @@ -409,43 +413,63 @@ final class AccessibilityViewEmbedder { } private static class ReflectionAccessors { - private final Method getSourceNodeId; - private final Method getParentNodeId; - private final Method getRecordSourceNodeId; - private final Method getChildId; + 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; - // Reflection access is not allowed starting Android P. + 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 { - getSourceNodeId = AccessibilityNodeInfo.class.getMethod("getSourceNodeId"); - } catch (NoSuchMethodException e) { - Log.w(TAG, "can't invoke AccessibilityNodeInfo#getSourceNodeId with reflection"); - } try { getParentNodeId = AccessibilityNodeInfo.class.getMethod("getParentNodeId"); } catch (NoSuchMethodException e) { Log.w(TAG, "can't invoke getParentNodeId with reflection"); } - try { - getRecordSourceNodeId = AccessibilityRecord.class.getMethod("getSourceNodeId"); - } catch (NoSuchMethodException e) { - Log.w(TAG, "can't invoke AccessibiiltyRecord#getSourceNodeId 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. */ @@ -470,32 +494,105 @@ final class AccessibilityViewEmbedder { @Nullable private Long getChildId(@NonNull AccessibilityNodeInfo node, int child) { - if (getChildId == null) { + if (getChildId == null && (childNodeIdsField == null || longArrayGetIndex == null)) { return null; } - try { - return (Long) getChildId.invoke(node, child); - } catch (IllegalAccessException e) { - Log.w(TAG, e); - } catch (InvocationTargetException e) { - Log.w(TAG, e); + 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) { + 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; } - try { - return (long) getParentNodeId.invoke(node); - } catch (IllegalAccessException e) { - Log.w(TAG, e); - } catch (InvocationTargetException e) { - Log.w(TAG, e); + + // 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 } - return null; + 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