diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter index d6d82f05202..4c361acfe94 100755 --- a/engine/src/flutter/ci/licenses_golden/licenses_flutter +++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter @@ -1111,6 +1111,7 @@ FILE: ../../../flutter/shell/platform/darwin/macos/framework/Headers/FlutterView FILE: ../../../flutter/shell/platform/darwin/macos/framework/Info.plist FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.h FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.mm +FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegateTest.mm FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterAppDelegate.mm FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterBackingStore.h FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterBackingStore.mm diff --git a/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn b/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn index 872c4a681d6..77c83bb7ac6 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn @@ -167,6 +167,7 @@ executable("flutter_desktop_darwin_unittests") { testonly = true sources = [ + "framework/Source/AccessibilityBridgeMacDelegateTest.mm", "framework/Source/FlutterChannelKeyResponderUnittests.mm", "framework/Source/FlutterEmbedderExternalTextureUnittests.mm", "framework/Source/FlutterEmbedderKeyResponderUnittests.mm", diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.h b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.h index b2977e514d2..e192732c508 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.h +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.h @@ -48,8 +48,8 @@ class AccessibilityBridgeMacDelegate : public AccessibilityBridge::Accessibility /// accessibility notification system. /// @param[in] native_node The event target, must not be nil. /// @param[in] mac_notification The event name, must not be nil. - void DispatchMacOSNotification(gfx::NativeViewAccessible native_node, - NSAccessibilityNotificationName mac_notification); + virtual void DispatchMacOSNotification(gfx::NativeViewAccessible native_node, + NSAccessibilityNotificationName mac_notification); //--------------------------------------------------------------------------- /// @brief Posts the given event against the given node with the diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.mm index 193c1ea135d..5db727eb28c 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.mm @@ -6,6 +6,7 @@ #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h" #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.h" +#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h" #include "flutter/shell/platform/embedder/embedder.h" namespace flutter { @@ -26,6 +27,10 @@ AccessibilityBridgeMacDelegate::AccessibilityBridgeMacDelegate(__weak FlutterEng void AccessibilityBridgeMacDelegate::OnAccessibilityEvent( ui::AXEventGenerator::TargetedEvent targeted_event) { + if (!flutter_engine_.viewController.viewLoaded || !flutter_engine_.viewController.view.window) { + // We don't need to send accessibility events if the there is no view or window. + return; + } ui::AXNode* ax_node = targeted_event.node; std::vector events = MacOSEventsFromAXEvent(targeted_event.event_params.event, *ax_node); @@ -265,9 +270,10 @@ AccessibilityBridgeMacDelegate::MacOSEventsFromAXEvent(ui::AXEventGenerator::Eve case ui::AXEventGenerator::Event::CHILDREN_CHANGED: { // NSAccessibilityCreatedNotification seems to be the only way to let // Voiceover pick up layout changes. + NSCAssert(flutter_engine_.viewController, @"The viewController must not be nil"); events.push_back({ .name = NSAccessibilityCreatedNotification, - .target = [NSApp mainWindow], + .target = flutter_engine_.viewController.view.window, .user_info = nil, }); break; @@ -335,6 +341,9 @@ void AccessibilityBridgeMacDelegate::DispatchAccessibilityAction(ui::AXNode::AXI FlutterSemanticsAction action, const std::vector& data) { NSCAssert(flutter_engine_, @"Flutter engine should not be deallocated"); + NSCAssert(flutter_engine_.viewController.viewLoaded && flutter_engine_.viewController.view.window, + @"The accessibility bridge should not receive accessibility actions if the flutter view" + @"is not loaded or attached to a NSWindow."); [flutter_engine_ dispatchSemanticsAction:action toTarget:target withData:data]; } diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegateTest.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegateTest.mm new file mode 100644 index 00000000000..4bd7166bf35 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegateTest.mm @@ -0,0 +1,194 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +#include "flutter/testing/testing.h" + +#import "flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.h" +#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterDartProject_Internal.h" +#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h" +#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h" +namespace flutter::testing { + +namespace { + +class AccessibilityBridgeMacDelegateSpy : public AccessibilityBridgeMacDelegate { + public: + AccessibilityBridgeMacDelegateSpy(__weak FlutterEngine* flutter_engine) + : AccessibilityBridgeMacDelegate(flutter_engine) {} + + std::unordered_map actual_notifications; + + private: + void DispatchMacOSNotification(gfx::NativeViewAccessible native_node, + NSAccessibilityNotificationName mac_notification) override { + actual_notifications[[mac_notification UTF8String]] = native_node; + } +}; + +// Returns an engine configured for the text fixture resource configuration. +FlutterEngine* CreateTestEngine() { + NSString* fixtures = @(testing::GetFixturesPath()); + FlutterDartProject* project = [[FlutterDartProject alloc] + initWithAssetsPath:fixtures + ICUDataPath:[fixtures stringByAppendingString:@"/icudtl.dat"]]; + return [[FlutterEngine alloc] initWithName:@"test" project:project allowHeadlessExecution:true]; +} +} // namespace + +TEST(AccessibilityBridgeMacDelegateTest, + sendsAccessibilityCreateNotificationToWindowOfFlutterView) { + FlutterEngine* engine = CreateTestEngine(); + NSString* fixtures = @(testing::GetFixturesPath()); + FlutterDartProject* project = [[FlutterDartProject alloc] + initWithAssetsPath:fixtures + ICUDataPath:[fixtures stringByAppendingString:@"/icudtl.dat"]]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithProject:project]; + [viewController loadView]; + [engine setViewController:viewController]; + + NSWindow* expectedTarget = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600) + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO]; + expectedTarget.contentView = viewController.view; + // Setting up bridge so that the AccessibilityBridgeMacDelegateSpy + // can query semantics information from. + engine.semanticsEnabled = YES; + auto bridge = engine.accessibilityBridge.lock(); + FlutterSemanticsNode root; + root.id = 0; + root.flags = static_cast(0); + root.actions = static_cast(0); + root.text_selection_base = -1; + root.text_selection_extent = -1; + root.label = "root"; + root.hint = ""; + root.value = ""; + root.increased_value = ""; + root.decreased_value = ""; + root.child_count = 0; + root.custom_accessibility_actions_count = 0; + bridge->AddFlutterSemanticsNodeUpdate(&root); + + bridge->CommitUpdates(); + auto platform_node_delegate = bridge->GetFlutterPlatformNodeDelegateFromID(0).lock(); + + AccessibilityBridgeMacDelegateSpy spy(engine); + + // Creates a targeted event. + ui::AXTree tree; + ui::AXNode ax_node(&tree, nullptr, 0, 0); + ui::AXNodeData node_data; + node_data.id = 0; + ax_node.SetData(node_data); + std::vector intent; + ui::AXEventGenerator::EventParams event_params(ui::AXEventGenerator::Event::CHILDREN_CHANGED, + ax::mojom::EventFrom::kNone, intent); + ui::AXEventGenerator::TargetedEvent targeted_event(&ax_node, event_params); + + spy.OnAccessibilityEvent(targeted_event); + + EXPECT_EQ(spy.actual_notifications.size(), 1u); + EXPECT_EQ(spy.actual_notifications.find([NSAccessibilityCreatedNotification UTF8String])->second, + expectedTarget); + [engine shutDownEngine]; +} + +TEST(AccessibilityBridgeMacDelegateTest, doesNotSendAccessibilityCreateNotificationWhenHeadless) { + FlutterEngine* engine = CreateTestEngine(); + // Setting up bridge so that the AccessibilityBridgeMacDelegateSpy + // can query semantics information from. + engine.semanticsEnabled = YES; + auto bridge = engine.accessibilityBridge.lock(); + FlutterSemanticsNode root; + root.id = 0; + root.flags = static_cast(0); + root.actions = static_cast(0); + root.text_selection_base = -1; + root.text_selection_extent = -1; + root.label = "root"; + root.hint = ""; + root.value = ""; + root.increased_value = ""; + root.decreased_value = ""; + root.child_count = 0; + root.custom_accessibility_actions_count = 0; + bridge->AddFlutterSemanticsNodeUpdate(&root); + + bridge->CommitUpdates(); + auto platform_node_delegate = bridge->GetFlutterPlatformNodeDelegateFromID(0).lock(); + + AccessibilityBridgeMacDelegateSpy spy(engine); + + // Creates a targeted event. + ui::AXTree tree; + ui::AXNode ax_node(&tree, nullptr, 0, 0); + ui::AXNodeData node_data; + node_data.id = 0; + ax_node.SetData(node_data); + std::vector intent; + ui::AXEventGenerator::EventParams event_params(ui::AXEventGenerator::Event::CHILDREN_CHANGED, + ax::mojom::EventFrom::kNone, intent); + ui::AXEventGenerator::TargetedEvent targeted_event(&ax_node, event_params); + + spy.OnAccessibilityEvent(targeted_event); + + // Does not send any notification if the engine is headless. + EXPECT_EQ(spy.actual_notifications.size(), 0u); + [engine shutDownEngine]; +} + +TEST(AccessibilityBridgeMacDelegateTest, doesNotSendAccessibilityCreateNotificationWhenNoWindow) { + FlutterEngine* engine = CreateTestEngine(); + // Create a view controller without attaching it to a window. + NSString* fixtures = @(testing::GetFixturesPath()); + FlutterDartProject* project = [[FlutterDartProject alloc] + initWithAssetsPath:fixtures + ICUDataPath:[fixtures stringByAppendingString:@"/icudtl.dat"]]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithProject:project]; + [viewController loadView]; + [engine setViewController:viewController]; + + // Setting up bridge so that the AccessibilityBridgeMacDelegateSpy + // can query semantics information from. + engine.semanticsEnabled = YES; + auto bridge = engine.accessibilityBridge.lock(); + FlutterSemanticsNode root; + root.id = 0; + root.flags = static_cast(0); + root.actions = static_cast(0); + root.text_selection_base = -1; + root.text_selection_extent = -1; + root.label = "root"; + root.hint = ""; + root.value = ""; + root.increased_value = ""; + root.decreased_value = ""; + root.child_count = 0; + root.custom_accessibility_actions_count = 0; + bridge->AddFlutterSemanticsNodeUpdate(&root); + + bridge->CommitUpdates(); + auto platform_node_delegate = bridge->GetFlutterPlatformNodeDelegateFromID(0).lock(); + + AccessibilityBridgeMacDelegateSpy spy(engine); + + // Creates a targeted event. + ui::AXTree tree; + ui::AXNode ax_node(&tree, nullptr, 0, 0); + ui::AXNodeData node_data; + node_data.id = 0; + ax_node.SetData(node_data); + std::vector intent; + ui::AXEventGenerator::EventParams event_params(ui::AXEventGenerator::Event::CHILDREN_CHANGED, + ax::mojom::EventFrom::kNone, intent); + ui::AXEventGenerator::TargetedEvent targeted_event(&ax_node, event_params); + + spy.OnAccessibilityEvent(targeted_event); + + // Does not send any notification if the flutter view is not attached to a NSWindow. + EXPECT_EQ(spy.actual_notifications.size(), 0u); + [engine shutDownEngine]; +} + +} // flutter::testing diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm index 60a94b2e8e5..da747aed483 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm @@ -156,4 +156,87 @@ TEST(FlutterEngine, CanToggleAccessibility) { [engine shutDownEngine]; } +TEST(FlutterEngine, CanToggleAccessibilityWhenHeadless) { + FlutterEngine* engine = CreateTestEngine(); + // Capture the update callbacks before the embedder API initializes. + auto original_init = engine.embedderAPI.Initialize; + std::function update_node_callback; + std::function update_action_callback; + engine.embedderAPI.Initialize = MOCK_ENGINE_PROC( + Initialize, ([&update_action_callback, &update_node_callback, &original_init]( + size_t version, const FlutterRendererConfig* config, + const FlutterProjectArgs* args, void* user_data, auto engine_out) { + update_node_callback = args->update_semantics_node_callback; + update_action_callback = args->update_semantics_custom_action_callback; + return original_init(version, config, args, user_data, engine_out); + })); + EXPECT_TRUE([engine runWithEntrypoint:@"main"]); + + // Enable the semantics without attaching a view controller. + bool enabled_called = false; + engine.embedderAPI.UpdateSemanticsEnabled = + MOCK_ENGINE_PROC(UpdateSemanticsEnabled, ([&enabled_called](auto engine, bool enabled) { + enabled_called = enabled; + return kSuccess; + })); + engine.semanticsEnabled = YES; + EXPECT_TRUE(enabled_called); + // Send flutter semantics updates. + FlutterSemanticsNode root; + root.id = 0; + root.flags = static_cast(0); + root.actions = static_cast(0); + root.text_selection_base = -1; + root.text_selection_extent = -1; + root.label = "root"; + root.hint = ""; + root.value = ""; + root.increased_value = ""; + root.decreased_value = ""; + root.child_count = 1; + int32_t children[] = {1}; + root.children_in_traversal_order = children; + root.custom_accessibility_actions_count = 0; + update_node_callback(&root, (void*)CFBridgingRetain(engine)); + + FlutterSemanticsNode child1; + child1.id = 1; + child1.flags = static_cast(0); + child1.actions = static_cast(0); + child1.text_selection_base = -1; + child1.text_selection_extent = -1; + child1.label = "child 1"; + child1.hint = ""; + child1.value = ""; + child1.increased_value = ""; + child1.decreased_value = ""; + child1.child_count = 0; + child1.custom_accessibility_actions_count = 0; + update_node_callback(&child1, (void*)CFBridgingRetain(engine)); + + FlutterSemanticsNode node_batch_end; + node_batch_end.id = kFlutterSemanticsNodeIdBatchEnd; + update_node_callback(&node_batch_end, (void*)CFBridgingRetain(engine)); + + FlutterSemanticsCustomAction action_batch_end; + action_batch_end.id = kFlutterSemanticsNodeIdBatchEnd; + update_action_callback(&action_batch_end, (void*)CFBridgingRetain(engine)); + + // No crashes. + EXPECT_EQ(engine.viewController, nil); + + // Disable the semantics. + bool semanticsEnabled = true; + engine.embedderAPI.UpdateSemanticsEnabled = + MOCK_ENGINE_PROC(UpdateSemanticsEnabled, ([&semanticsEnabled](auto engine, bool enabled) { + semanticsEnabled = enabled; + return kSuccess; + })); + engine.semanticsEnabled = NO; + EXPECT_FALSE(semanticsEnabled); + // Still no crashes + EXPECT_EQ(engine.viewController, nil); + [engine shutDownEngine]; +} + } // namespace flutter::testing diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMacTest.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMacTest.mm index e4c1bd81c8d..067904b4b73 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMacTest.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMacTest.mm @@ -9,6 +9,7 @@ #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterDartProject_Internal.h" #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h" #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.h" +#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h" #include "flutter/shell/platform/embedder/test_utils/proc_table_replacement.h" #include "flutter/third_party/accessibility/ax/ax_action_data.h" @@ -135,6 +136,22 @@ TEST(FlutterPlatformNodeDelegateMac, SelectableTextWithoutSelectionReturnZeroRan TEST(FlutterPlatformNodeDelegateMac, CanPerformAction) { FlutterEngine* engine = CreateTestEngine(); + + // Set up view controller. + NSString* fixtures = @(testing::GetFixturesPath()); + FlutterDartProject* project = [[FlutterDartProject alloc] + initWithAssetsPath:fixtures + ICUDataPath:[fixtures stringByAppendingString:@"/icudtl.dat"]]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithProject:project]; + [engine setViewController:viewController]; + + // Attach the view to a NSWindow. + NSWindow* window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600) + styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered + defer:NO]; + window.contentView = viewController.view; + engine.semanticsEnabled = YES; auto bridge = engine.accessibilityBridge.lock(); // Initialize ax node data. @@ -186,6 +203,8 @@ TEST(FlutterPlatformNodeDelegateMac, CanPerformAction) { EXPECT_EQ(called_action, FlutterSemanticsAction::kFlutterSemanticsActionTap); EXPECT_EQ(called_id, 1u); + + [engine setViewController:nil]; [engine shutDownEngine]; }