From 4842a3a52ea0dcaadad4b16fd45691f1671ea27e Mon Sep 17 00:00:00 2001 From: Emmanuel Garcia Date: Fri, 23 Apr 2021 11:16:09 -0700 Subject: [PATCH] Fix accessibility of embedded views in Android 9 and below (flutter/engine#25709) --- .../flutter/shell/platform/android/BUILD.gn | 1 + .../embedding/android/FlutterView.java | 81 +++++++++++++++++++ .../android/test/io/flutter/TestUtils.java | 45 +++++++++++ .../embedding/android/FlutterViewTest.java | 47 +++++++++++ .../localization/LocalizationPluginTest.java | 50 +++--------- 5 files changed, 183 insertions(+), 41 deletions(-) create mode 100644 engine/src/flutter/shell/platform/android/test/io/flutter/TestUtils.java diff --git a/engine/src/flutter/shell/platform/android/BUILD.gn b/engine/src/flutter/shell/platform/android/BUILD.gn index 69ab05fc049..3a84c422586 100644 --- a/engine/src/flutter/shell/platform/android/BUILD.gn +++ b/engine/src/flutter/shell/platform/android/BUILD.gn @@ -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", diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterView.java index ea02a08fd23..70d5ad60065 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -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. + * + *

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. + * + *

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) { diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/TestUtils.java b/engine/src/flutter/shell/platform/android/test/io/flutter/TestUtils.java new file mode 100644 index 00000000000..ae2e79eda18 --- /dev/null +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/TestUtils.java @@ -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); + } + } +} diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java index 26e73416503..e74b9fd8ce3 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java @@ -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 */ diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/localization/LocalizationPluginTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/localization/LocalizationPluginTest.java index d07b122f302..a46c8b4844e 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/localization/LocalizationPluginTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/localization/LocalizationPluginTest.java @@ -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); - } - } }