mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Prevent TalkBack from announcing view in non-virtual a11y hierarchies (flutter/engine#34017)
This commit is contained in:
parent
2df62cd1b8
commit
f99e4563b9
@ -3,6 +3,7 @@ package io.flutter.embedding.engine.mutatorsstack;
|
||||
import static android.view.View.OnFocusChangeListener;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
@ -10,6 +11,7 @@ import android.graphics.Path;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import android.widget.FrameLayout;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@ -21,6 +23,7 @@ import io.flutter.util.ViewUtils;
|
||||
* A view that applies the {@link io.flutter.embedding.engine.mutatorsstack.FlutterMutatorsStack} to
|
||||
* its children.
|
||||
*/
|
||||
@TargetApi(19)
|
||||
public class FlutterMutatorView extends FrameLayout {
|
||||
private FlutterMutatorsStack mutatorsStack;
|
||||
private float screenDensity;
|
||||
@ -160,6 +163,21 @@ public class FlutterMutatorView extends FrameLayout {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestSendAccessibilityEvent(View child, AccessibilityEvent event) {
|
||||
final View embeddedView = getChildAt(0);
|
||||
if (embeddedView != null
|
||||
&& embeddedView.getImportantForAccessibility()
|
||||
== View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
|
||||
return false;
|
||||
}
|
||||
// Forward the request only if the embedded view is in the Flutter accessibility tree.
|
||||
// The embedded view may be ignored when the framework doesn't populate a SemanticNode
|
||||
// for the current platform view.
|
||||
// See AccessibilityBridge for more.
|
||||
return super.requestSendAccessibilityEvent(child, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
|
||||
@ -21,6 +21,7 @@ import android.view.Surface;
|
||||
import android.view.View;
|
||||
import android.view.ViewParent;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import android.widget.FrameLayout;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@ -242,6 +243,21 @@ class PlatformViewWrapper extends FrameLayout {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestSendAccessibilityEvent(View child, AccessibilityEvent event) {
|
||||
final View embeddedView = getChildAt(0);
|
||||
if (embeddedView != null
|
||||
&& embeddedView.getImportantForAccessibility()
|
||||
== View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
|
||||
return false;
|
||||
}
|
||||
// Forward the request only if the embedded view is in the Flutter accessibility tree.
|
||||
// The embedded view may be ignored when the framework doesn't populate a SemanticNode
|
||||
// for the current platform view.
|
||||
// See AccessibilityBridge for more.
|
||||
return super.requestSendAccessibilityEvent(child, event);
|
||||
}
|
||||
|
||||
/** Used on Android O+, {@link invalidateChildInParent} used for previous versions. */
|
||||
@SuppressLint("NewApi")
|
||||
@Override
|
||||
|
||||
@ -223,17 +223,28 @@ public class PlatformViewsController implements PlatformViewsAccessibilityDelega
|
||||
layoutParams.leftMargin = physicalLeft;
|
||||
wrapperView.setLayoutParams(layoutParams);
|
||||
|
||||
final View view = platformView.getView();
|
||||
if (view == null) {
|
||||
final View embeddedView = platformView.getView();
|
||||
if (embeddedView == null) {
|
||||
throw new IllegalStateException(
|
||||
"PlatformView#getView() returned null, but an Android view reference was expected.");
|
||||
} else if (view.getParent() != null) {
|
||||
} else if (embeddedView.getParent() != null) {
|
||||
throw new IllegalStateException(
|
||||
"The Android view returned from PlatformView#getView() was already added to a parent view.");
|
||||
}
|
||||
view.setLayoutParams(new FrameLayout.LayoutParams(physicalWidth, physicalHeight));
|
||||
view.setLayoutDirection(request.direction);
|
||||
wrapperView.addView(view);
|
||||
embeddedView.setLayoutParams(new FrameLayout.LayoutParams(physicalWidth, physicalHeight));
|
||||
embeddedView.setLayoutDirection(request.direction);
|
||||
|
||||
// Accessibility in the embedded view is initially disabled because if a Flutter app
|
||||
// disabled accessibility in the first frame, the embedding won't receive an update to
|
||||
// disable accessibility since the embedding never received an update to enable it.
|
||||
// The AccessibilityBridge keeps track of the accessibility nodes, and handles the deltas
|
||||
// when the framework sends a new a11y tree to the embedding.
|
||||
// To prevent races, the framework populate the SemanticsNode after the platform view has
|
||||
// been created.
|
||||
embeddedView.setImportantForAccessibility(
|
||||
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
|
||||
wrapperView.addView(embeddedView);
|
||||
wrapperView.setOnDescendantFocusChangeListener(
|
||||
(v, hasFocus) -> {
|
||||
if (hasFocus) {
|
||||
@ -738,6 +749,7 @@ public class PlatformViewsController implements PlatformViewsAccessibilityDelega
|
||||
* testing.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
@TargetApi(Build.VERSION_CODES.KITKAT)
|
||||
void initializePlatformViewIfNeeded(int viewId) {
|
||||
final PlatformView platformView = platformViews.get(viewId);
|
||||
if (platformView == null) {
|
||||
@ -747,11 +759,12 @@ public class PlatformViewsController implements PlatformViewsAccessibilityDelega
|
||||
if (platformViewParent.get(viewId) != null) {
|
||||
return;
|
||||
}
|
||||
if (platformView.getView() == null) {
|
||||
final View embeddedView = platformView.getView();
|
||||
if (embeddedView == null) {
|
||||
throw new IllegalStateException(
|
||||
"PlatformView#getView() returned null, but an Android view reference was expected.");
|
||||
}
|
||||
if (platformView.getView().getParent() != null) {
|
||||
if (embeddedView.getParent() != null) {
|
||||
throw new IllegalStateException(
|
||||
"The Android view returned from PlatformView#getView() was already added to a parent view.");
|
||||
}
|
||||
@ -769,7 +782,17 @@ public class PlatformViewsController implements PlatformViewsAccessibilityDelega
|
||||
});
|
||||
|
||||
platformViewParent.put(viewId, parentView);
|
||||
parentView.addView(platformView.getView());
|
||||
|
||||
// Accessibility in the embedded view is initially disabled because if a Flutter app disabled
|
||||
// accessibility in the first frame, the embedding won't receive an update to disable
|
||||
// accessibility since the embedding never received an update to enable it.
|
||||
// The AccessibilityBridge keeps track of the accessibility nodes, and handles the deltas when
|
||||
// the framework sends a new a11y tree to the embedding.
|
||||
// To prevent races, the framework populate the SemanticsNode after the platform view has been
|
||||
// created.
|
||||
embeddedView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
|
||||
parentView.addView(embeddedView);
|
||||
flutterView.addView(parentView);
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,10 @@ import static org.mockito.Mockito.*;
|
||||
import android.content.Context;
|
||||
import android.graphics.Matrix;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import io.flutter.embedding.android.AndroidTouchProcessor;
|
||||
@ -15,6 +18,8 @@ import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.annotation.Implementation;
|
||||
import org.robolectric.annotation.Implements;
|
||||
|
||||
@Config(manifest = Config.NONE)
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
@ -206,4 +211,54 @@ public class FlutterMutatorViewTest {
|
||||
view.unsetOnDescendantFocusChangeListener();
|
||||
verify(viewTreeObserver, times(1)).removeOnGlobalFocusChangeListener(activeFocusListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(
|
||||
shadows = {
|
||||
ShadowViewGroup.class,
|
||||
})
|
||||
public void ignoreAccessibilityEvents() {
|
||||
final FlutterMutatorView wrapperView = new FlutterMutatorView(ctx);
|
||||
|
||||
final View embeddedView = mock(View.class);
|
||||
wrapperView.addView(embeddedView);
|
||||
|
||||
when(embeddedView.getImportantForAccessibility())
|
||||
.thenReturn(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
final boolean eventSent =
|
||||
wrapperView.requestSendAccessibilityEvent(embeddedView, mock(AccessibilityEvent.class));
|
||||
assertFalse(eventSent);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(
|
||||
shadows = {
|
||||
ShadowViewGroup.class,
|
||||
})
|
||||
public void sendAccessibilityEvents() {
|
||||
final FlutterMutatorView wrapperView = new FlutterMutatorView(ctx);
|
||||
|
||||
final View embeddedView = mock(View.class);
|
||||
wrapperView.addView(embeddedView);
|
||||
|
||||
when(embeddedView.getImportantForAccessibility())
|
||||
.thenReturn(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
|
||||
boolean eventSent =
|
||||
wrapperView.requestSendAccessibilityEvent(embeddedView, mock(AccessibilityEvent.class));
|
||||
assertTrue(eventSent);
|
||||
|
||||
when(embeddedView.getImportantForAccessibility())
|
||||
.thenReturn(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
|
||||
eventSent =
|
||||
wrapperView.requestSendAccessibilityEvent(embeddedView, mock(AccessibilityEvent.class));
|
||||
assertTrue(eventSent);
|
||||
}
|
||||
|
||||
@Implements(ViewGroup.class)
|
||||
public static class ShadowViewGroup extends org.robolectric.shadows.ShadowView {
|
||||
@Implementation
|
||||
public boolean requestSendAccessibilityEvent(View child, AccessibilityEvent event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,9 @@ import android.graphics.Color;
|
||||
import android.graphics.SurfaceTexture;
|
||||
import android.view.Surface;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
@ -21,6 +23,7 @@ import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.annotation.Implementation;
|
||||
import org.robolectric.annotation.Implements;
|
||||
|
||||
@TargetApi(31)
|
||||
@ -295,6 +298,56 @@ public class PlatformViewWrapperTest {
|
||||
verify(viewTreeObserver, times(1)).removeOnGlobalFocusChangeListener(activeFocusListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(
|
||||
shadows = {
|
||||
ShadowViewGroup.class,
|
||||
})
|
||||
public void ignoreAccessibilityEvents() {
|
||||
final PlatformViewWrapper wrapperView = new PlatformViewWrapper(ctx);
|
||||
|
||||
final View embeddedView = mock(View.class);
|
||||
wrapperView.addView(embeddedView);
|
||||
|
||||
when(embeddedView.getImportantForAccessibility())
|
||||
.thenReturn(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
final boolean eventSent =
|
||||
wrapperView.requestSendAccessibilityEvent(embeddedView, mock(AccessibilityEvent.class));
|
||||
assertFalse(eventSent);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(
|
||||
shadows = {
|
||||
ShadowViewGroup.class,
|
||||
})
|
||||
public void sendAccessibilityEvents() {
|
||||
final PlatformViewWrapper wrapperView = new PlatformViewWrapper(ctx);
|
||||
|
||||
final View embeddedView = mock(View.class);
|
||||
wrapperView.addView(embeddedView);
|
||||
|
||||
when(embeddedView.getImportantForAccessibility())
|
||||
.thenReturn(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
|
||||
boolean eventSent =
|
||||
wrapperView.requestSendAccessibilityEvent(embeddedView, mock(AccessibilityEvent.class));
|
||||
assertTrue(eventSent);
|
||||
|
||||
when(embeddedView.getImportantForAccessibility())
|
||||
.thenReturn(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
|
||||
eventSent =
|
||||
wrapperView.requestSendAccessibilityEvent(embeddedView, mock(AccessibilityEvent.class));
|
||||
assertTrue(eventSent);
|
||||
}
|
||||
|
||||
@Implements(View.class)
|
||||
public static class ShadowView {}
|
||||
|
||||
@Implements(ViewGroup.class)
|
||||
public static class ShadowViewGroup extends org.robolectric.shadows.ShadowView {
|
||||
@Implementation
|
||||
public boolean requestSendAccessibilityEvent(View child, AccessibilityEvent event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,6 +211,33 @@ public class PlatformViewsControllerTest {
|
||||
assertEquals(capturedLayoutParams.get(0).height, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(shadows = {ShadowFlutterJNI.class, ShadowPlatformTaskQueue.class})
|
||||
public void createPlatformViewMessage__disablesAccessibility() {
|
||||
PlatformViewsController platformViewsController = new PlatformViewsController();
|
||||
platformViewsController.setSoftwareRendering(true);
|
||||
|
||||
int platformViewId = 0;
|
||||
assertNull(platformViewsController.getPlatformViewById(platformViewId));
|
||||
|
||||
PlatformViewFactory viewFactory = mock(PlatformViewFactory.class);
|
||||
PlatformView platformView = mock(PlatformView.class);
|
||||
|
||||
View androidView = mock(View.class);
|
||||
when(platformView.getView()).thenReturn(androidView);
|
||||
when(viewFactory.create(any(), eq(platformViewId), any())).thenReturn(platformView);
|
||||
platformViewsController.getRegistry().registerViewFactory("testType", viewFactory);
|
||||
|
||||
FlutterJNI jni = new FlutterJNI();
|
||||
attach(jni, platformViewsController);
|
||||
|
||||
// Simulate create call from the framework.
|
||||
createPlatformView(
|
||||
jni, platformViewsController, platformViewId, "testType", /* hybrid=*/ false);
|
||||
verify(androidView, times(1))
|
||||
.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(shadows = {ShadowFlutterJNI.class, ShadowPlatformTaskQueue.class})
|
||||
public void createPlatformViewMessage__throwsIfViewIsNull() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user