diff --git a/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java b/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java index 9be312cc53baf..c195b28728e9b 100644 --- a/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java +++ b/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java @@ -3,6 +3,7 @@ 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.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 @@ * 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 boolean onInterceptTouchEvent(MotionEvent event) { 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) { diff --git a/shell/platform/android/io/flutter/plugin/platform/PlatformViewWrapper.java b/shell/platform/android/io/flutter/plugin/platform/PlatformViewWrapper.java index d21782181c454..c57d90f0162c1 100644 --- a/shell/platform/android/io/flutter/plugin/platform/PlatformViewWrapper.java +++ b/shell/platform/android/io/flutter/plugin/platform/PlatformViewWrapper.java @@ -21,6 +21,7 @@ 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 @@ public boolean onInterceptTouchEvent(@NonNull MotionEvent event) { 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 diff --git a/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java b/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java index 80e270821ed19..8008da1a77b28 100644 --- a/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java +++ b/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java @@ -223,17 +223,28 @@ public long createForTextureLayer( 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 @@ private void initializeRootImageViewIfNeeded() { * testing. */ @VisibleForTesting + @TargetApi(Build.VERSION_CODES.KITKAT) void initializePlatformViewIfNeeded(int viewId) { final PlatformView platformView = platformViews.get(viewId); if (platformView == null) { @@ -747,11 +759,12 @@ void initializePlatformViewIfNeeded(int viewId) { 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 @@ void initializePlatformViewIfNeeded(int viewId) { }); 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); } diff --git a/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java b/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java index 03624c1b509f2..2f3091f9baa4c 100644 --- a/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java +++ b/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java @@ -7,7 +7,10 @@ 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.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 ViewTreeObserver getViewTreeObserver() { 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; + } + } } diff --git a/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java b/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java index f1dffb24aa6c1..2893df22834f5 100644 --- a/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java +++ b/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java @@ -13,7 +13,9 @@ 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.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 ViewTreeObserver getViewTreeObserver() { 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; + } + } } diff --git a/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java b/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java index 0b6e4b8edd8e3..d80e59186998b 100644 --- a/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java +++ b/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java @@ -211,6 +211,33 @@ public void createPlatformViewMessage__setsAndroidViewSize() { 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() {