mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Fix accessibility of embedded views in Android 9 and below (flutter/engine#25709)
This commit is contained in:
parent
852165f4dd
commit
4842a3a52e
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user