Prevent TalkBack from announcing view in non-virtual a11y hierarchies (flutter/engine#34017)

This commit is contained in:
Emmanuel Garcia 2022-06-14 12:03:05 -07:00 committed by GitHub
parent 2df62cd1b8
commit f99e4563b9
6 changed files with 201 additions and 9 deletions

View File

@ -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) {

View File

@ -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

View File

@ -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);
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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() {