Fix accessibility of embedded views in Android 9 and below (flutter/engine#25709)

This commit is contained in:
Emmanuel Garcia 2021-04-23 11:16:09 -07:00 committed by GitHub
parent 852165f4dd
commit 4842a3a52e
5 changed files with 183 additions and 41 deletions

View File

@ -456,6 +456,7 @@ action("robolectric_tests") {
"test/io/flutter/FlutterInjectorTest.java",
"test/io/flutter/FlutterTestSuite.java",
"test/io/flutter/SmokeTest.java",
"test/io/flutter/TestUtils.java",
"test/io/flutter/embedding/android/AndroidKeyProcessorTest.java",
"test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java",
"test/io/flutter/embedding/android/FlutterActivityTest.java",

View File

@ -20,6 +20,7 @@ import android.view.MotionEvent;
import android.view.PointerIcon;
import android.view.Surface;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStructure;
import android.view.WindowInsets;
import android.view.WindowManager;
@ -44,6 +45,8 @@ import io.flutter.plugin.localization.LocalizationPlugin;
import io.flutter.plugin.mouse.MouseCursorPlugin;
import io.flutter.plugin.platform.PlatformViewsController;
import io.flutter.view.AccessibilityBridge;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.Set;
@ -826,6 +829,84 @@ public class FlutterView extends FrameLayout implements MouseCursorPlugin.MouseC
}
}
/**
* Prior to Android Q, it's impossible to add real views as descendants of virtual nodes. This
* breaks accessibility when an Android view is embedded in a Flutter app.
*
* <p>This method overrides a @hide method in {@code ViewGroup} to workaround this limitation.
* This solution is derivated from Jetpack Compose, and can be found in the Android source code as
* well.
*
* <p>This workaround finds the descendant {@code View} that matches the provided accessibility
* id.
*
* @param accessibilityId The view accessibility id.
* @return The view matching the accessibility id if any.
*/
@SuppressLint("PrivateApi")
public View findViewByAccessibilityIdTraversal(int accessibilityId) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return findViewByAccessibilityIdRootedAtCurrentView(accessibilityId, this);
}
// Android Q or later doesn't call this method.
//
// However, since this is implementation detail, a future version of Android might call
// this method again, fallback to calling the @hide method as expected by ViewGroup.
Method findViewByAccessibilityIdTraversalMethod;
try {
findViewByAccessibilityIdTraversalMethod =
View.class.getDeclaredMethod("findViewByAccessibilityIdTraversal", int.class);
} catch (NoSuchMethodException exception) {
return null;
}
findViewByAccessibilityIdTraversalMethod.setAccessible(true);
try {
return (View) findViewByAccessibilityIdTraversalMethod.invoke(this, accessibilityId);
} catch (IllegalAccessException exception) {
return null;
} catch (InvocationTargetException exception) {
return null;
}
}
/**
* Finds the descendant view that matches the provided accessibility id.
*
* @param accessibilityId The view accessibility id.
* @param currentView The root view.
* @return A descendant of currentView or currentView itself.
*/
@SuppressLint("PrivateApi")
private View findViewByAccessibilityIdRootedAtCurrentView(int accessibilityId, View currentView) {
Method getAccessibilityViewIdMethod;
try {
getAccessibilityViewIdMethod = View.class.getDeclaredMethod("getAccessibilityViewId");
} catch (NoSuchMethodException exception) {
return null;
}
getAccessibilityViewIdMethod.setAccessible(true);
try {
if (getAccessibilityViewIdMethod.invoke(currentView).equals(accessibilityId)) {
return currentView;
}
} catch (IllegalAccessException exception) {
return null;
} catch (InvocationTargetException exception) {
return null;
}
if (currentView instanceof ViewGroup) {
for (int i = 0; i < ((ViewGroup) currentView).getChildCount(); i++) {
View view =
findViewByAccessibilityIdRootedAtCurrentView(
accessibilityId, ((ViewGroup) currentView).getChildAt(i));
if (view != null) {
return view;
}
}
}
return null;
}
// TODO(mattcarroll): Confer with Ian as to why we need this method. Delete if possible, otherwise
// add comments.
private void resetWillNotDraw(boolean isAccessibilityEnabled, boolean isTouchExplorationEnabled) {

View File

@ -0,0 +1,45 @@
// 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;
import static org.junit.Assert.assertTrue;
import android.content.res.Configuration;
import android.os.Build;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Locale;
public class TestUtils {
public static void setApiVersion(int apiVersion) {
try {
Field field = Build.VERSION.class.getField("SDK_INT");
field.setAccessible(true);
Field modifiersField = Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
field.set(null, apiVersion);
} catch (Exception e) {
assertTrue(false);
}
}
public static void setLegacyLocale(Configuration config, Locale locale) {
try {
Field field = config.getClass().getField("locale");
field.setAccessible(true);
Field modifiersField = Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
field.set(config, locale);
} catch (Exception e) {
assertTrue(false);
}
}
}

View File

@ -28,12 +28,15 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.widget.FrameLayout;
import io.flutter.TestUtils;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.embedding.engine.FlutterJNI;
import io.flutter.embedding.engine.loader.FlutterLoader;
import io.flutter.embedding.engine.renderer.FlutterRenderer;
import io.flutter.embedding.engine.systemchannels.SettingsChannel;
import io.flutter.plugin.platform.PlatformViewsController;
import java.lang.reflect.Method;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Before;
import org.junit.Test;
@ -745,6 +748,50 @@ public class FlutterViewTest {
verify(mockRegion, times(1)).op(0, 0, 0, 0, Region.Op.DIFFERENCE);
}
@Test
@SuppressLint("PrivateApi")
public void findViewByAccessibilityIdTraversal_returnsRootViewOnAndroid28() throws Exception {
TestUtils.setApiVersion(28);
FlutterView flutterView = new FlutterView(RuntimeEnvironment.application);
Method getAccessibilityViewIdMethod = View.class.getDeclaredMethod("getAccessibilityViewId");
Integer accessibilityViewId = (Integer) getAccessibilityViewIdMethod.invoke(flutterView);
assertEquals(flutterView, flutterView.findViewByAccessibilityIdTraversal(accessibilityViewId));
}
@Test
@SuppressLint("PrivateApi")
public void findViewByAccessibilityIdTraversal_returnsChildViewOnAndroid28() throws Exception {
TestUtils.setApiVersion(28);
FlutterView flutterView = new FlutterView(RuntimeEnvironment.application);
FrameLayout childView1 = new FrameLayout(RuntimeEnvironment.application);
flutterView.addView(childView1);
FrameLayout childView2 = new FrameLayout(RuntimeEnvironment.application);
childView1.addView(childView2);
Method getAccessibilityViewIdMethod = View.class.getDeclaredMethod("getAccessibilityViewId");
Integer accessibilityViewId = (Integer) getAccessibilityViewIdMethod.invoke(childView2);
assertEquals(childView2, flutterView.findViewByAccessibilityIdTraversal(accessibilityViewId));
}
@Test
@SuppressLint("PrivateApi")
public void findViewByAccessibilityIdTraversal_returnsRootViewOnAndroid29() throws Exception {
TestUtils.setApiVersion(29);
FlutterView flutterView = new FlutterView(RuntimeEnvironment.application);
Method getAccessibilityViewIdMethod = View.class.getDeclaredMethod("getAccessibilityViewId");
Integer accessibilityViewId = (Integer) getAccessibilityViewIdMethod.invoke(flutterView);
assertEquals(null, flutterView.findViewByAccessibilityIdTraversal(accessibilityViewId));
}
/*
* A custom shadow that reports fullscreen flag for system UI visibility
*/

View File

@ -2,7 +2,6 @@
package io.flutter.embedding.engine;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@ -12,15 +11,13 @@ import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Build;
import android.os.LocaleList;
import io.flutter.TestUtils;
import io.flutter.embedding.engine.dart.DartExecutor;
import io.flutter.embedding.engine.systemchannels.LocalizationChannel;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.localization.LocalizationPlugin;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Locale;
import org.json.JSONException;
import org.json.JSONObject;
@ -37,7 +34,7 @@ public class LocalizationPluginTest {
@Test
public void computePlatformResolvedLocaleAPI26() {
// --- Test Setup ---
setApiVersion(26);
TestUtils.setApiVersion(26);
FlutterJNI flutterJNI = new FlutterJNI();
Context context = mock(Context.class);
@ -145,7 +142,7 @@ public class LocalizationPluginTest {
@Test
public void computePlatformResolvedLocaleAPI24() {
// --- Test Setup ---
setApiVersion(24);
TestUtils.setApiVersion(24);
FlutterJNI flutterJNI = new FlutterJNI();
Context context = mock(Context.class);
@ -240,7 +237,7 @@ public class LocalizationPluginTest {
@Test
public void computePlatformResolvedLocaleAPI16() {
// --- Test Setup ---
setApiVersion(16);
TestUtils.setApiVersion(16);
FlutterJNI flutterJNI = new FlutterJNI();
Context context = mock(Context.class);
@ -250,7 +247,7 @@ public class LocalizationPluginTest {
Locale userLocale = new Locale("es", "MX");
when(context.getResources()).thenReturn(resources);
when(resources.getConfiguration()).thenReturn(config);
setLegacyLocale(config, userLocale);
TestUtils.setLegacyLocale(config, userLocale);
flutterJNI.setLocalizationPlugin(
new LocalizationPlugin(context, new LocalizationChannel(dartExecutor)));
@ -268,7 +265,7 @@ public class LocalizationPluginTest {
"en", "CA", ""
};
userLocale = null;
setLegacyLocale(config, userLocale);
TestUtils.setLegacyLocale(config, userLocale);
result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
// The first locale is default.
assertEquals(result.length, 3);
@ -286,7 +283,7 @@ public class LocalizationPluginTest {
"it", "IT", ""
};
userLocale = new Locale("fr", "CH");
setLegacyLocale(config, userLocale);
TestUtils.setLegacyLocale(config, userLocale);
result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
assertEquals(result.length, 3);
assertEquals(result[0], "en");
@ -302,7 +299,7 @@ public class LocalizationPluginTest {
"it", "IT", ""
};
userLocale = new Locale("it", "IT");
setLegacyLocale(config, userLocale);
TestUtils.setLegacyLocale(config, userLocale);
result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
assertEquals(result.length, 3);
assertEquals(result[0], "it");
@ -319,7 +316,7 @@ public class LocalizationPluginTest {
"it", "IT", ""
};
userLocale = new Locale("fr", "CH");
setLegacyLocale(config, userLocale);
TestUtils.setLegacyLocale(config, userLocale);
result = flutterJNI.computePlatformResolvedLocale(supportedLocales);
assertEquals(result.length, 3);
assertEquals(result[0], "fr");
@ -452,33 +449,4 @@ public class LocalizationPluginTest {
verify(mockResult).success(null);
}
private static void setApiVersion(int apiVersion) {
try {
Field field = Build.VERSION.class.getField("SDK_INT");
field.setAccessible(true);
Field modifiersField = Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
field.set(null, apiVersion);
} catch (Exception e) {
assertTrue(false);
}
}
private static void setLegacyLocale(Configuration config, Locale locale) {
try {
Field field = config.getClass().getField("locale");
field.setAccessible(true);
Field modifiersField = Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
field.set(config, locale);
} catch (Exception e) {
assertTrue(false);
}
}
}