diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java index 3fb32814f56b8..38059232ce1a7 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java @@ -2,7 +2,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; import io.flutter.Log; import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.dart.DartExecutor; @@ -24,8 +23,7 @@ public class AccessibilityChannel { @NonNull public final FlutterJNI flutterJNI; @Nullable private AccessibilityMessageHandler handler; - @VisibleForTesting - final BasicMessageChannel.MessageHandler parsingMessageHandler = + public final BasicMessageChannel.MessageHandler parsingMessageHandler = new BasicMessageChannel.MessageHandler() { @Override public void onMessage( @@ -67,6 +65,14 @@ public void onMessage( } break; } + case "focus": + { + Integer nodeId = (Integer) annotatedEvent.get("nodeId"); + if (nodeId != null) { + handler.onFocus(nodeId); + } + break; + } case "tooltip": { String tooltipMessage = (String) data.get("message"); @@ -170,12 +176,15 @@ public interface AccessibilityMessageHandler extends FlutterJNI.AccessibilityDel /** The Dart application would like the given {@code message} to be announced. */ void announce(@NonNull String message); - /** The user has tapped on the widget with the given {@code nodeId}. */ + /** The user has tapped on the semantics node with the given {@code nodeId}. */ void onTap(int nodeId); - /** The user has long pressed on the widget with the given {@code nodeId}. */ + /** The user has long pressed on the semantics node with the given {@code nodeId}. */ void onLongPress(int nodeId); + /** The framework has requested focus on the semantics node with the given {@code nodeId}. */ + void onFocus(int nodeId); + /** The user has opened a tooltip. */ void onTooltip(@NonNull String message); } diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index b121f531e2f1e..3ade64dff204b 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -309,6 +309,12 @@ public void onLongPress(int nodeId) { sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_LONG_CLICKED); } + /** The framework has requested focus on the given {@code nodeId}. */ + @Override + public void onFocus(int nodeId) { + sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_FOCUSED); + } + /** The user has opened a tooltip. */ @Override public void onTooltip(@NonNull String message) { @@ -1883,7 +1889,8 @@ private AccessibilityEvent createTextChangedEvent(int id, String oldValue, Strin *

The given {@code viewId} may either belong to {@link #rootAccessibilityView}, or any Flutter * {@link SemanticsNode}. */ - private void sendAccessibilityEvent(int viewId, int eventType) { + @VisibleForTesting + public void sendAccessibilityEvent(int viewId, int eventType) { if (!accessibilityManager.isEnabled()) { return; } @@ -1976,12 +1983,17 @@ private void sendWindowContentChangeEvent(int virtualViewId) { * invoked to create an {@link AccessibilityEvent} for the {@link #rootAccessibilityView}. */ private AccessibilityEvent obtainAccessibilityEvent(int virtualViewId, int eventType) { - AccessibilityEvent event = AccessibilityEvent.obtain(eventType); + AccessibilityEvent event = obtainAccessibilityEvent(eventType); event.setPackageName(rootAccessibilityView.getContext().getPackageName()); event.setSource(rootAccessibilityView, virtualViewId); return event; } + @VisibleForTesting + public AccessibilityEvent obtainAccessibilityEvent(int eventType) { + return AccessibilityEvent.obtain(eventType); + } + /** * Reads the {@code layoutInDisplayCutoutMode} value from the window attribute and returns whether * a left cutout inset is required. diff --git a/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/AccessibilityChannelTest.java b/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/AccessibilityChannelTest.java index adf121a99fdbb..5ee17c647983a 100644 --- a/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/AccessibilityChannelTest.java +++ b/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/AccessibilityChannelTest.java @@ -7,6 +7,7 @@ import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.dart.DartExecutor; import io.flutter.plugin.common.BasicMessageChannel; +import java.util.HashMap; import org.json.JSONException; import org.json.JSONObject; import org.junit.Test; @@ -30,4 +31,19 @@ public void repliesWhenNoAccessibilityHandler() throws JSONException { accessibilityChannel.parsingMessageHandler.onMessage(arguments, reply); verify(reply).reply(null); } + + @Test + public void handleFocus() throws JSONException { + AccessibilityChannel accessibilityChannel = + new AccessibilityChannel(mock(DartExecutor.class), mock(FlutterJNI.class)); + HashMap arguments = new HashMap<>(); + arguments.put("type", "focus"); + arguments.put("nodeId", 123); + AccessibilityChannel.AccessibilityMessageHandler handler = + mock(AccessibilityChannel.AccessibilityMessageHandler.class); + accessibilityChannel.setAccessibilityMessageHandler(handler); + BasicMessageChannel.Reply reply = mock(BasicMessageChannel.Reply.class); + accessibilityChannel.parsingMessageHandler.onMessage(arguments, reply); + verify(handler).onFocus(123); + } } diff --git a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index 105d8a7a984b6..434843e1a8748 100644 --- a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -44,12 +44,16 @@ import android.view.accessibility.AccessibilityNodeInfo; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import io.flutter.embedding.engine.FlutterJNI; +import io.flutter.embedding.engine.dart.DartExecutor; import io.flutter.embedding.engine.systemchannels.AccessibilityChannel; +import io.flutter.plugin.common.BasicMessageChannel; import io.flutter.plugin.platform.PlatformViewsAccessibilityDelegate; import io.flutter.view.AccessibilityBridge.Flag; import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; @@ -1827,6 +1831,66 @@ public void releaseDropsChannelMessageHandler() { verify(mockChannel, never()).setAccessibilityFeatures(anyInt()); } + @Test + public void sendFocusAccessibilityEvent() { + AccessibilityManager mockManager = mock(AccessibilityManager.class); + AccessibilityChannel accessibilityChannel = + new AccessibilityChannel(mock(DartExecutor.class), mock(FlutterJNI.class)); + + ContentResolver mockContentResolver = mock(ContentResolver.class); + View mockRootView = mock(View.class); + Context context = mock(Context.class); + when(mockRootView.getContext()).thenReturn(context); + when(context.getPackageName()).thenReturn("test"); + ViewParent mockParent = mock(ViewParent.class); + when(mockRootView.getParent()).thenReturn(mockParent); + when(mockManager.isEnabled()).thenReturn(true); + + AccessibilityBridge accessibilityBridge = + setUpBridge(mockRootView, accessibilityChannel, mockManager, null, null, null); + + HashMap arguments = new HashMap<>(); + arguments.put("type", "focus"); + arguments.put("nodeId", 123); + BasicMessageChannel.Reply reply = mock(BasicMessageChannel.Reply.class); + accessibilityChannel.parsingMessageHandler.onMessage(arguments, reply); + + // Check that focus event was sent. + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(AccessibilityEvent.class); + verify(mockParent).requestSendAccessibilityEvent(eq(mockRootView), eventCaptor.capture()); + AccessibilityEvent event = eventCaptor.getAllValues().get(0); + assertEquals(event.getEventType(), AccessibilityEvent.TYPE_VIEW_FOCUSED); + assertEquals(event.getSource(), null); + } + + @Test + public void SetSourceAndPackageNameForAccessibilityEvent() { + AccessibilityManager mockManager = mock(AccessibilityManager.class); + ContentResolver mockContentResolver = mock(ContentResolver.class); + View mockRootView = mock(View.class); + Context context = mock(Context.class); + when(mockRootView.getContext()).thenReturn(context); + when(context.getPackageName()).thenReturn("test"); + when(mockManager.isEnabled()).thenReturn(true); + ViewParent mockParent = mock(ViewParent.class); + when(mockRootView.getParent()).thenReturn(mockParent); + AccessibilityEvent mockEvent = mock(AccessibilityEvent.class); + + AccessibilityBridge accessibilityBridge = + setUpBridge(mockRootView, null, mockManager, null, null, null); + + AccessibilityBridge spyAccessibilityBridge = spy(accessibilityBridge); + + when(spyAccessibilityBridge.obtainAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)) + .thenReturn(mockEvent); + + spyAccessibilityBridge.sendAccessibilityEvent(123, AccessibilityEvent.TYPE_VIEW_FOCUSED); + + verify(mockEvent).setPackageName("test"); + verify(mockEvent).setSource(eq(mockRootView), eq(123)); + } + AccessibilityBridge setUpBridge() { return setUpBridge(null, null, null, null, null, null); } diff --git a/shell/platform/darwin/ios/framework/Source/accessibility_bridge.h b/shell/platform/darwin/ios/framework/Source/accessibility_bridge.h index bebb2e7e699c1..451dbce83db42 100644 --- a/shell/platform/darwin/ios/framework/Source/accessibility_bridge.h +++ b/shell/platform/darwin/ios/framework/Source/accessibility_bridge.h @@ -57,6 +57,7 @@ class AccessibilityBridge final : public AccessibilityBridgeIos { void UpdateSemantics(flutter::SemanticsNodeUpdates nodes, const flutter::CustomAccessibilityActionUpdates& actions); + void HandleEvent(NSDictionary* annotatedEvent); void DispatchSemanticsAction(int32_t id, flutter::SemanticsAction action) override; void DispatchSemanticsAction(int32_t id, flutter::SemanticsAction action, @@ -88,7 +89,6 @@ class AccessibilityBridge final : public AccessibilityBridgeIos { SemanticsObject* FindFirstFocusable(SemanticsObject* parent); void VisitObjectsRecursivelyAndRemove(SemanticsObject* object, NSMutableArray* doomed_uids); - void HandleEvent(NSDictionary* annotatedEvent); FlutterViewController* view_controller_; PlatformViewIOS* platform_view_; diff --git a/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm b/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm index 4f8525a1aa158..2121a2ac69586 100644 --- a/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm +++ b/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm @@ -362,6 +362,10 @@ static bool DidFlagChange(const flutter::SemanticsNode& oldNode, NSString* message = annotatedEvent[@"data"][@"message"]; ios_delegate_->PostAccessibilityNotification(UIAccessibilityAnnouncementNotification, message); } + if ([type isEqualToString:@"focus"]) { + SemanticsObject* node = objects_.get()[annotatedEvent[@"nodeId"]]; + ios_delegate_->PostAccessibilityNotification(UIAccessibilityLayoutChangedNotification, node); + } } fml::WeakPtr AccessibilityBridge::GetWeakPtr() { diff --git a/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm b/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm index c617284489e73..40995c9111284 100644 --- a/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm +++ b/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm @@ -1289,6 +1289,50 @@ - (void)testAnnouncesRouteChangesRemoveRouteInMiddle { UIAccessibilityScreenChangedNotification); } +- (void)testHandleEvent { + flutter::MockDelegate mock_delegate; + auto thread_task_runner = CreateNewThread("AccessibilityBridgeTest"); + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/thread_task_runner, + /*raster=*/thread_task_runner, + /*ui=*/thread_task_runner, + /*io=*/thread_task_runner); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/flutter::IOSRenderingAPI::kSoftware, + /*platform_views_controller=*/nil, + /*task_runners=*/runners, + /*worker_task_runner=*/nil, + /*is_gpu_disabled_sync_switch=*/nil); + id mockFlutterView = OCMClassMock([FlutterView class]); + id mockFlutterViewController = OCMClassMock([FlutterViewController class]); + OCMStub([mockFlutterViewController view]).andReturn(mockFlutterView); + + NSMutableArray*>* accessibility_notifications = + [[[NSMutableArray alloc] init] autorelease]; + auto ios_delegate = std::make_unique(); + ios_delegate->on_PostAccessibilityNotification_ = + [accessibility_notifications](UIAccessibilityNotifications notification, id argument) { + [accessibility_notifications addObject:@{ + @"notification" : @(notification), + @"argument" : argument ? argument : [NSNull null], + }]; + }; + __block auto bridge = + std::make_unique(/*view_controller=*/mockFlutterViewController, + /*platform_view=*/platform_view.get(), + /*platform_views_controller=*/nil, + /*ios_delegate=*/std::move(ios_delegate)); + + NSDictionary* annotatedEvent = @{@"type" : @"focus", @"nodeId" : @123}; + + bridge->HandleEvent(annotatedEvent); + + XCTAssertEqual([accessibility_notifications count], 1ul); + XCTAssertEqual([accessibility_notifications[0][@"notification"] unsignedIntValue], + UIAccessibilityLayoutChangedNotification); +} + - (void)testAnnouncesRouteChangesWhenNoNamesRoute { flutter::MockDelegate mock_delegate; auto thread_task_runner = CreateNewThread("AccessibilityBridgeTest");