Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit f4d6ce1

Browse files
authored
Clear focus if a platform view goes away (#17381)
1 parent ef161fb commit f4d6ce1

File tree

3 files changed

+173
-22
lines changed

3 files changed

+173
-22
lines changed

shell/platform/android/io/flutter/view/AccessibilityBridge.java

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import androidx.annotation.NonNull;
2727
import androidx.annotation.Nullable;
2828
import androidx.annotation.RequiresApi;
29+
import androidx.annotation.VisibleForTesting;
2930
import io.flutter.BuildConfig;
3031
import io.flutter.embedding.engine.systemchannels.AccessibilityChannel;
3132
import io.flutter.plugin.platform.PlatformViewsAccessibilityDelegate;
@@ -333,10 +334,32 @@ public AccessibilityBridge(
333334
// TODO(mattcarrol): Add the annotation once the plumbing is done.
334335
// https://github.com/flutter/flutter/issues/29618
335336
PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate) {
337+
this(
338+
rootAccessibilityView,
339+
accessibilityChannel,
340+
accessibilityManager,
341+
contentResolver,
342+
new AccessibilityViewEmbedder(rootAccessibilityView, MIN_ENGINE_GENERATED_NODE_ID),
343+
platformViewsAccessibilityDelegate);
344+
}
345+
346+
@VisibleForTesting
347+
public AccessibilityBridge(
348+
@NonNull View rootAccessibilityView,
349+
@NonNull AccessibilityChannel accessibilityChannel,
350+
@NonNull AccessibilityManager accessibilityManager,
351+
@NonNull ContentResolver contentResolver,
352+
@NonNull AccessibilityViewEmbedder accessibilityViewEmbedder,
353+
// This should be @NonNull once the plumbing for
354+
// io.flutter.embedding.engine.android.FlutterView is done.
355+
// TODO(mattcarrol): Add the annotation once the plumbing is done.
356+
// https://github.com/flutter/flutter/issues/29618
357+
PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate) {
336358
this.rootAccessibilityView = rootAccessibilityView;
337359
this.accessibilityChannel = accessibilityChannel;
338360
this.accessibilityManager = accessibilityManager;
339361
this.contentResolver = contentResolver;
362+
this.accessibilityViewEmbedder = accessibilityViewEmbedder;
340363
this.platformViewsAccessibilityDelegate = platformViewsAccessibilityDelegate;
341364

342365
// Tell Flutter whether accessibility is initially active or not. Then register a listener
@@ -388,8 +411,6 @@ public void onTouchExplorationStateChanged(boolean isTouchExplorationEnabled) {
388411
if (platformViewsAccessibilityDelegate != null) {
389412
platformViewsAccessibilityDelegate.attachAccessibilityBridge(this);
390413
}
391-
accessibilityViewEmbedder =
392-
new AccessibilityViewEmbedder(rootAccessibilityView, MIN_ENGINE_GENERATED_NODE_ID);
393414
}
394415

395416
/**
@@ -1580,15 +1601,31 @@ private void willRemoveSemanticsNode(SemanticsNode semanticsNodeToBeRemoved) {
15801601
// for null'ing accessibilityFocusedSemanticsNode, inputFocusedSemanticsNode,
15811602
// and hoveredObject. Is this a hook method or a command?
15821603
semanticsNodeToBeRemoved.parent = null;
1604+
1605+
if (semanticsNodeToBeRemoved.platformViewId != -1
1606+
&& embeddedAccessibilityFocusedNodeId != null
1607+
&& accessibilityViewEmbedder.platformViewOfNode(embeddedAccessibilityFocusedNodeId)
1608+
== platformViewsAccessibilityDelegate.getPlatformViewById(
1609+
semanticsNodeToBeRemoved.platformViewId)) {
1610+
// If the currently focused a11y node is within a platform view that is
1611+
// getting removed: clear it's a11y focus.
1612+
sendAccessibilityEvent(
1613+
embeddedAccessibilityFocusedNodeId,
1614+
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
1615+
embeddedAccessibilityFocusedNodeId = null;
1616+
}
1617+
15831618
if (accessibilityFocusedSemanticsNode == semanticsNodeToBeRemoved) {
15841619
sendAccessibilityEvent(
15851620
accessibilityFocusedSemanticsNode.id,
15861621
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
15871622
accessibilityFocusedSemanticsNode = null;
15881623
}
1624+
15891625
if (inputFocusedSemanticsNode == semanticsNodeToBeRemoved) {
15901626
inputFocusedSemanticsNode = null;
15911627
}
1628+
15921629
if (hoveredObject == semanticsNodeToBeRemoved) {
15931630
hoveredObject = null;
15941631
}

shell/platform/android/io/flutter/view/AccessibilityViewEmbedder.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
* corresponding platform view and `originId`.
4545
*/
4646
@Keep
47-
final class AccessibilityViewEmbedder {
47+
class AccessibilityViewEmbedder {
4848
private static final String TAG = "AccessibilityBridge";
4949

5050
private final ReflectionAccessors reflectionAccessors;
@@ -387,6 +387,18 @@ public boolean onAccessibilityHoverEvent(int rootFlutterId, @NonNull MotionEvent
387387
return origin.view.dispatchGenericMotionEvent(translatedEvent);
388388
}
389389

390+
/**
391+
* Returns the View that contains the accessibility node identified by the provided flutterId or
392+
* null if it doesn't belong to a view.
393+
*/
394+
public View platformViewOfNode(int flutterId) {
395+
ViewAndId viewAndId = flutterIdToOrigin.get(flutterId);
396+
if (viewAndId == null) {
397+
return null;
398+
}
399+
return viewAndId.view;
400+
}
401+
390402
private static class ViewAndId {
391403
final View view;
392404
final int id;

shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java

Lines changed: 121 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,27 @@
55
package io.flutter.view;
66

77
import static org.junit.Assert.assertEquals;
8+
import static org.mockito.Matchers.eq;
89
import static org.mockito.Mockito.mock;
10+
import static org.mockito.Mockito.times;
11+
import static org.mockito.Mockito.verify;
912
import static org.mockito.Mockito.when;
1013

1114
import android.content.ContentResolver;
1215
import android.content.Context;
1316
import android.view.View;
17+
import android.view.ViewParent;
18+
import android.view.accessibility.AccessibilityEvent;
1419
import android.view.accessibility.AccessibilityManager;
1520
import android.view.accessibility.AccessibilityNodeInfo;
1621
import io.flutter.embedding.engine.systemchannels.AccessibilityChannel;
1722
import io.flutter.plugin.platform.PlatformViewsAccessibilityDelegate;
1823
import java.nio.ByteBuffer;
1924
import java.util.ArrayList;
25+
import java.util.List;
2026
import org.junit.Test;
2127
import org.junit.runner.RunWith;
28+
import org.mockito.ArgumentCaptor;
2229
import org.robolectric.RobolectricTestRunner;
2330
import org.robolectric.annotation.Config;
2431

@@ -73,24 +80,103 @@ public void itDoesNotContainADescriptionIfScopesRoute() {
7380
assertEquals(nodeInfo.getText(), null);
7481
}
7582

76-
AccessibilityBridge setUpBridge() {
77-
View view = mock(View.class);
83+
@Test
84+
public void itUnfocusesPlatformViewWhenPlatformViewGoesAway() {
85+
AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class);
86+
AccessibilityManager mockManager = mock(AccessibilityManager.class);
87+
View mockRootView = mock(View.class);
7888
Context context = mock(Context.class);
79-
when(view.getContext()).thenReturn(context);
89+
when(mockRootView.getContext()).thenReturn(context);
8090
when(context.getPackageName()).thenReturn("test");
81-
AccessibilityChannel accessibilityChannel = mock(AccessibilityChannel.class);
82-
AccessibilityManager accessibilityManager = mock(AccessibilityManager.class);
83-
ContentResolver contentResolver = mock(ContentResolver.class);
84-
PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate =
85-
mock(PlatformViewsAccessibilityDelegate.class);
8691
AccessibilityBridge accessibilityBridge =
87-
new AccessibilityBridge(
88-
view,
89-
accessibilityChannel,
90-
accessibilityManager,
91-
contentResolver,
92-
platformViewsAccessibilityDelegate);
93-
return accessibilityBridge;
92+
setUpBridge(mockRootView, mockManager, mockViewEmbedder);
93+
94+
// Sent a11y tree with platform view.
95+
TestSemanticsNode root = new TestSemanticsNode();
96+
root.id = 0;
97+
TestSemanticsNode platformView = new TestSemanticsNode();
98+
platformView.id = 1;
99+
platformView.platformViewId = 42;
100+
root.children.add(platformView);
101+
TestSemanticsUpdate testSemanticsUpdate = root.toUpdate();
102+
accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings);
103+
104+
// Set a11y focus to platform view.
105+
View mockView = mock(View.class);
106+
AccessibilityEvent focusEvent = mock(AccessibilityEvent.class);
107+
when(mockViewEmbedder.requestSendAccessibilityEvent(mockView, mockView, focusEvent))
108+
.thenReturn(true);
109+
when(mockViewEmbedder.getRecordFlutterId(mockView, focusEvent)).thenReturn(42);
110+
when(focusEvent.getEventType()).thenReturn(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
111+
accessibilityBridge.externalViewRequestSendAccessibilityEvent(mockView, mockView, focusEvent);
112+
113+
// Replace the platform view.
114+
TestSemanticsNode node = new TestSemanticsNode();
115+
node.id = 2;
116+
root.children.clear();
117+
root.children.add(node);
118+
testSemanticsUpdate = root.toUpdate();
119+
when(mockManager.isEnabled()).thenReturn(true);
120+
ViewParent mockParent = mock(ViewParent.class);
121+
when(mockRootView.getParent()).thenReturn(mockParent);
122+
accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings);
123+
124+
// Check that unfocus event was sent.
125+
ArgumentCaptor<AccessibilityEvent> eventCaptor =
126+
ArgumentCaptor.forClass(AccessibilityEvent.class);
127+
verify(mockParent, times(2))
128+
.requestSendAccessibilityEvent(eq(mockRootView), eventCaptor.capture());
129+
AccessibilityEvent event = eventCaptor.getAllValues().get(0);
130+
assertEquals(event.getEventType(), AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
131+
}
132+
133+
AccessibilityBridge setUpBridge() {
134+
return setUpBridge(null, null, null, null, null, null);
135+
}
136+
137+
AccessibilityBridge setUpBridge(
138+
View rootAccessibilityView,
139+
AccessibilityManager accessibilityManager,
140+
AccessibilityViewEmbedder accessibilityViewEmbedder) {
141+
return setUpBridge(
142+
rootAccessibilityView, null, accessibilityManager, null, accessibilityViewEmbedder, null);
143+
}
144+
145+
AccessibilityBridge setUpBridge(
146+
View rootAccessibilityView,
147+
AccessibilityChannel accessibilityChannel,
148+
AccessibilityManager accessibilityManager,
149+
ContentResolver contentResolver,
150+
AccessibilityViewEmbedder accessibilityViewEmbedder,
151+
PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate) {
152+
if (rootAccessibilityView == null) {
153+
rootAccessibilityView = mock(View.class);
154+
Context context = mock(Context.class);
155+
when(rootAccessibilityView.getContext()).thenReturn(context);
156+
when(context.getPackageName()).thenReturn("test");
157+
}
158+
if (accessibilityChannel == null) {
159+
accessibilityChannel = mock(AccessibilityChannel.class);
160+
}
161+
if (accessibilityManager == null) {
162+
accessibilityManager = mock(AccessibilityManager.class);
163+
}
164+
if (contentResolver == null) {
165+
contentResolver = mock(ContentResolver.class);
166+
}
167+
if (accessibilityViewEmbedder == null) {
168+
accessibilityViewEmbedder = mock(AccessibilityViewEmbedder.class);
169+
}
170+
if (platformViewsAccessibilityDelegate == null) {
171+
platformViewsAccessibilityDelegate = mock(PlatformViewsAccessibilityDelegate.class);
172+
}
173+
return new AccessibilityBridge(
174+
rootAccessibilityView,
175+
accessibilityChannel,
176+
accessibilityManager,
177+
contentResolver,
178+
accessibilityViewEmbedder,
179+
platformViewsAccessibilityDelegate);
94180
}
95181

96182
/// The encoding for semantics is described in platform_view_android.cc
@@ -136,11 +222,18 @@ void addFlag(AccessibilityBridge.Flag flag) {
136222
float top = 0.0f;
137223
float right = 0.0f;
138224
float bottom = 0.0f;
139-
// children and custom actions not supported.
225+
final List<TestSemanticsNode> children = new ArrayList<TestSemanticsNode>();
226+
// custom actions not supported.
140227

141228
TestSemanticsUpdate toUpdate() {
142229
ArrayList<String> strings = new ArrayList<String>();
143230
ByteBuffer bytes = ByteBuffer.allocate(1000);
231+
addToBuffer(bytes, strings);
232+
bytes.flip();
233+
return new TestSemanticsUpdate(bytes, strings.toArray(new String[strings.size()]));
234+
}
235+
236+
protected void addToBuffer(ByteBuffer bytes, ArrayList<String> strings) {
144237
bytes.putInt(id);
145238
bytes.putInt(flags);
146239
bytes.putInt(actions);
@@ -169,11 +262,20 @@ TestSemanticsUpdate toUpdate() {
169262
bytes.putFloat(0);
170263
}
171264
// children in traversal order.
172-
bytes.putInt(0);
265+
bytes.putInt(children.size());
266+
for (TestSemanticsNode node : children) {
267+
bytes.putInt(node.id);
268+
}
269+
// children in hit test order.
270+
for (TestSemanticsNode node : children) {
271+
bytes.putInt(node.id);
272+
}
173273
// custom actions
174274
bytes.putInt(0);
175-
bytes.flip();
176-
return new TestSemanticsUpdate(bytes, strings.toArray(new String[strings.size()]));
275+
// child nodes
276+
for (TestSemanticsNode node : children) {
277+
node.addToBuffer(bytes, strings);
278+
}
177279
}
178280
}
179281

0 commit comments

Comments
 (0)