| // 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. |
| |
| #import "flutter/shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.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/FlutterTextInputSemanticsObject.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h" |
| #include "flutter/shell/platform/embedder/embedder.h" |
| |
| namespace flutter { |
| |
| inline constexpr int32_t kRootNode = 0; |
| |
| // Native mac notifications fired. These notifications are not publicly documented. |
| static NSString* const AccessibilityLoadCompleteNotification = @"AXLoadComplete"; |
| static NSString* const AccessibilityInvalidStatusChangedNotification = @"AXInvalidStatusChanged"; |
| static NSString* const AccessibilityLiveRegionCreatedNotification = @"AXLiveRegionCreated"; |
| static NSString* const AccessibilityLiveRegionChangedNotification = @"AXLiveRegionChanged"; |
| static NSString* const AccessibilityExpandedChanged = @"AXExpandedChanged"; |
| static NSString* const AccessibilityMenuItemSelectedNotification = @"AXMenuItemSelected"; |
| |
| AccessibilityBridgeMacDelegate::AccessibilityBridgeMacDelegate( |
| __weak FlutterEngine* flutter_engine, |
| __weak FlutterViewController* view_controller) |
| : flutter_engine_(flutter_engine), view_controller_(view_controller) {} |
| |
| void AccessibilityBridgeMacDelegate::OnAccessibilityEvent( |
| ui::AXEventGenerator::TargetedEvent targeted_event) { |
| if (!flutter_engine_.viewController.viewLoaded || !flutter_engine_.viewController.view.window) { |
| // 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<AccessibilityBridgeMacDelegate::NSAccessibilityEvent> events = |
| MacOSEventsFromAXEvent(targeted_event.event_params.event, *ax_node); |
| for (AccessibilityBridgeMacDelegate::NSAccessibilityEvent event : events) { |
| if (event.user_info != nil) { |
| DispatchMacOSNotificationWithUserInfo(event.target, event.name, event.user_info); |
| } else { |
| DispatchMacOSNotification(event.target, event.name); |
| } |
| } |
| } |
| |
| std::vector<AccessibilityBridgeMacDelegate::NSAccessibilityEvent> |
| AccessibilityBridgeMacDelegate::MacOSEventsFromAXEvent(ui::AXEventGenerator::Event event_type, |
| const ui::AXNode& ax_node) const { |
| // Gets the native_node with the node_id. |
| NSCAssert(flutter_engine_, @"Flutter engine should not be deallocated"); |
| auto bridge = flutter_engine_.accessibilityBridge.lock(); |
| NSCAssert(bridge, @"Accessibility bridge in flutter engine must not be null."); |
| auto platform_node_delegate = bridge->GetFlutterPlatformNodeDelegateFromID(ax_node.id()).lock(); |
| NSCAssert(platform_node_delegate, @"Event target must exist in accessibility bridge."); |
| auto mac_platform_node_delegate = |
| std::static_pointer_cast<FlutterPlatformNodeDelegateMac>(platform_node_delegate); |
| gfx::NativeViewAccessible native_node = mac_platform_node_delegate->GetNativeViewAccessible(); |
| |
| std::vector<AccessibilityBridgeMacDelegate::NSAccessibilityEvent> events; |
| switch (event_type) { |
| case ui::AXEventGenerator::Event::ACTIVE_DESCENDANT_CHANGED: |
| if (ax_node.data().role == ax::mojom::Role::kTree) { |
| events.push_back({ |
| .name = NSAccessibilitySelectedRowsChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| } else if (ax_node.data().role == ax::mojom::Role::kTextFieldWithComboBox) { |
| // Even though the selected item in the combo box has changed, don't |
| // post a focus change because this will take the focus out of |
| // the combo box where the user might be typing. |
| events.push_back({ |
| .name = NSAccessibilitySelectedChildrenChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| } |
| // In all other cases, this delegate should post |
| // |NSAccessibilityFocusedUIElementChangedNotification|, but this is |
| // handled elsewhere. |
| break; |
| case ui::AXEventGenerator::Event::LOAD_COMPLETE: |
| events.push_back({ |
| .name = AccessibilityLoadCompleteNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| break; |
| case ui::AXEventGenerator::Event::INVALID_STATUS_CHANGED: |
| events.push_back({ |
| .name = AccessibilityInvalidStatusChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| break; |
| case ui::AXEventGenerator::Event::SELECTED_CHILDREN_CHANGED: |
| if (ui::IsTableLike(ax_node.data().role)) { |
| events.push_back({ |
| .name = NSAccessibilitySelectedRowsChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| } else { |
| // VoiceOver does not read anything if selection changes on the |
| // currently focused object, and the focus did not move. Fire a |
| // selection change if the focus did not change. |
| NSAccessibilityElement* native_accessibility_node = (NSAccessibilityElement*)native_node; |
| if (native_accessibility_node.accessibilityFocusedUIElement && |
| ax_node.data().HasState(ax::mojom::State::kMultiselectable) && |
| !HasPendingEvent(ui::AXEventGenerator::Event::ACTIVE_DESCENDANT_CHANGED) && |
| !HasPendingEvent(ui::AXEventGenerator::Event::FOCUS_CHANGED)) { |
| // Don't fire selected children change, it will sometimes override |
| // announcement of current focus. |
| break; |
| } |
| events.push_back({ |
| .name = NSAccessibilitySelectedChildrenChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| } |
| break; |
| case ui::AXEventGenerator::Event::DOCUMENT_SELECTION_CHANGED: { |
| id focused = mac_platform_node_delegate->GetFocus(); |
| if ([focused isKindOfClass:[FlutterTextField class]]) { |
| // If it is a text field, the selection notifications are handled by |
| // the FlutterTextField directly. Only need to make sure it is the |
| // first responder. |
| FlutterTextField* native_text_field = (FlutterTextField*)focused; |
| if (native_text_field == mac_platform_node_delegate->GetFocus()) { |
| [native_text_field becomeFirstResponder]; |
| } |
| break; |
| } |
| // This event always fires at root |
| events.push_back({ |
| .name = NSAccessibilitySelectedTextChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| // WebKit fires a notification both on the focused object and the page |
| // root. |
| const ui::AXTreeData& tree_data = bridge->GetAXTreeData(); |
| int32_t focus = tree_data.focus_id; |
| if (focus == ui::AXNode::kInvalidAXID || focus != tree_data.sel_anchor_object_id) { |
| break; // Just fire a notification on the root. |
| } |
| auto focus_node = bridge->GetFlutterPlatformNodeDelegateFromID(focus).lock(); |
| if (!focus_node) { |
| break; // Just fire a notification on the root. |
| } |
| events.push_back({ |
| .name = NSAccessibilitySelectedTextChangedNotification, |
| .target = focus_node->GetNativeViewAccessible(), |
| .user_info = nil, |
| }); |
| break; |
| } |
| case ui::AXEventGenerator::Event::CHECKED_STATE_CHANGED: |
| events.push_back({ |
| .name = NSAccessibilityValueChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| break; |
| case ui::AXEventGenerator::Event::VALUE_CHANGED: { |
| if (ax_node.data().role == ax::mojom::Role::kTextField) { |
| // If it is a text field, the value change notifications are handled by |
| // the FlutterTextField directly. Only need to make sure it is the |
| // first responder. |
| FlutterTextField* native_text_field = |
| (FlutterTextField*)mac_platform_node_delegate->GetNativeViewAccessible(); |
| id focused = mac_platform_node_delegate->GetFocus(); |
| if (!focused || native_text_field == focused) { |
| [native_text_field becomeFirstResponder]; |
| } |
| break; |
| } |
| events.push_back({ |
| .name = NSAccessibilityValueChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| if (@available(macOS 10.11, *)) { |
| if (ax_node.data().HasState(ax::mojom::State::kEditable)) { |
| events.push_back({ |
| .name = NSAccessibilityValueChangedNotification, |
| .target = bridge->GetFlutterPlatformNodeDelegateFromID(kRootNode) |
| .lock() |
| ->GetNativeViewAccessible(), |
| .user_info = nil, |
| }); |
| } |
| } |
| break; |
| } |
| case ui::AXEventGenerator::Event::LIVE_REGION_CREATED: |
| events.push_back({ |
| .name = AccessibilityLiveRegionCreatedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| break; |
| case ui::AXEventGenerator::Event::ALERT: { |
| events.push_back({ |
| .name = AccessibilityLiveRegionCreatedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| // VoiceOver requires a live region changed notification to actually |
| // announce the live region. |
| auto live_region_events = |
| MacOSEventsFromAXEvent(ui::AXEventGenerator::Event::LIVE_REGION_CHANGED, ax_node); |
| events.insert(events.end(), live_region_events.begin(), live_region_events.end()); |
| break; |
| } |
| case ui::AXEventGenerator::Event::LIVE_REGION_CHANGED: { |
| if (@available(macOS 10.14, *)) { |
| // Do nothing on macOS >=10.14. |
| } else { |
| // Uses the announcement API to get around OS <= 10.13 VoiceOver bug |
| // where it stops announcing live regions after the first time focus |
| // leaves any content area. |
| // Unfortunately this produces an annoying boing sound with each live |
| // announcement, but the alternative is almost no live region support. |
| NSString* announcement = [[NSString alloc] |
| initWithUTF8String:mac_platform_node_delegate->GetLiveRegionText().c_str()]; |
| NSDictionary* notification_info = @{ |
| NSAccessibilityAnnouncementKey : announcement, |
| NSAccessibilityPriorityKey : @(NSAccessibilityPriorityLow) |
| }; |
| // Triggers VoiceOver speech and show on Braille display, if available. |
| // The Braille will only appear for a few seconds, and then will be replaced |
| // with the previous announcement. |
| events.push_back({ |
| .name = NSAccessibilityAnnouncementRequestedNotification, |
| .target = [NSApp mainWindow], |
| .user_info = notification_info, |
| }); |
| break; |
| } |
| // Uses native VoiceOver support for live regions. |
| events.push_back({ |
| .name = AccessibilityLiveRegionChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| break; |
| } |
| case ui::AXEventGenerator::Event::ROW_COUNT_CHANGED: |
| events.push_back({ |
| .name = NSAccessibilityRowCountChangedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| break; |
| case ui::AXEventGenerator::Event::EXPANDED: { |
| NSAccessibilityNotificationName mac_notification; |
| if (ax_node.data().role == ax::mojom::Role::kRow || |
| ax_node.data().role == ax::mojom::Role::kTreeItem) { |
| mac_notification = NSAccessibilityRowExpandedNotification; |
| } else { |
| mac_notification = AccessibilityExpandedChanged; |
| } |
| events.push_back({ |
| .name = mac_notification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| break; |
| } |
| case ui::AXEventGenerator::Event::COLLAPSED: { |
| NSAccessibilityNotificationName mac_notification; |
| if (ax_node.data().role == ax::mojom::Role::kRow || |
| ax_node.data().role == ax::mojom::Role::kTreeItem) { |
| mac_notification = NSAccessibilityRowCollapsedNotification; |
| } else { |
| mac_notification = AccessibilityExpandedChanged; |
| } |
| events.push_back({ |
| .name = mac_notification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| break; |
| } |
| case ui::AXEventGenerator::Event::MENU_ITEM_SELECTED: |
| events.push_back({ |
| .name = AccessibilityMenuItemSelectedNotification, |
| .target = native_node, |
| .user_info = nil, |
| }); |
| break; |
| 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 = flutter_engine_.viewController.view.window, |
| .user_info = nil, |
| }); |
| break; |
| } |
| case ui::AXEventGenerator::Event::SUBTREE_CREATED: |
| case ui::AXEventGenerator::Event::ACCESS_KEY_CHANGED: |
| case ui::AXEventGenerator::Event::ATK_TEXT_OBJECT_ATTRIBUTE_CHANGED: |
| case ui::AXEventGenerator::Event::ATOMIC_CHANGED: |
| case ui::AXEventGenerator::Event::AUTO_COMPLETE_CHANGED: |
| case ui::AXEventGenerator::Event::BUSY_CHANGED: |
| case ui::AXEventGenerator::Event::CONTROLS_CHANGED: |
| case ui::AXEventGenerator::Event::CLASS_NAME_CHANGED: |
| case ui::AXEventGenerator::Event::DESCRIBED_BY_CHANGED: |
| case ui::AXEventGenerator::Event::DESCRIPTION_CHANGED: |
| case ui::AXEventGenerator::Event::DOCUMENT_TITLE_CHANGED: |
| case ui::AXEventGenerator::Event::DROPEFFECT_CHANGED: |
| case ui::AXEventGenerator::Event::ENABLED_CHANGED: |
| case ui::AXEventGenerator::Event::FOCUS_CHANGED: |
| case ui::AXEventGenerator::Event::FLOW_FROM_CHANGED: |
| case ui::AXEventGenerator::Event::FLOW_TO_CHANGED: |
| case ui::AXEventGenerator::Event::GRABBED_CHANGED: |
| case ui::AXEventGenerator::Event::HASPOPUP_CHANGED: |
| case ui::AXEventGenerator::Event::HIERARCHICAL_LEVEL_CHANGED: |
| case ui::AXEventGenerator::Event::IGNORED_CHANGED: |
| case ui::AXEventGenerator::Event::IMAGE_ANNOTATION_CHANGED: |
| case ui::AXEventGenerator::Event::KEY_SHORTCUTS_CHANGED: |
| case ui::AXEventGenerator::Event::LABELED_BY_CHANGED: |
| case ui::AXEventGenerator::Event::LANGUAGE_CHANGED: |
| case ui::AXEventGenerator::Event::LAYOUT_INVALIDATED: |
| case ui::AXEventGenerator::Event::LIVE_REGION_NODE_CHANGED: |
| case ui::AXEventGenerator::Event::LIVE_RELEVANT_CHANGED: |
| case ui::AXEventGenerator::Event::LIVE_STATUS_CHANGED: |
| case ui::AXEventGenerator::Event::LOAD_START: |
| case ui::AXEventGenerator::Event::MULTILINE_STATE_CHANGED: |
| case ui::AXEventGenerator::Event::MULTISELECTABLE_STATE_CHANGED: |
| case ui::AXEventGenerator::Event::NAME_CHANGED: |
| case ui::AXEventGenerator::Event::OBJECT_ATTRIBUTE_CHANGED: |
| case ui::AXEventGenerator::Event::OTHER_ATTRIBUTE_CHANGED: |
| case ui::AXEventGenerator::Event::PLACEHOLDER_CHANGED: |
| case ui::AXEventGenerator::Event::PORTAL_ACTIVATED: |
| case ui::AXEventGenerator::Event::POSITION_IN_SET_CHANGED: |
| case ui::AXEventGenerator::Event::READONLY_CHANGED: |
| case ui::AXEventGenerator::Event::RELATED_NODE_CHANGED: |
| case ui::AXEventGenerator::Event::REQUIRED_STATE_CHANGED: |
| case ui::AXEventGenerator::Event::ROLE_CHANGED: |
| case ui::AXEventGenerator::Event::SCROLL_HORIZONTAL_POSITION_CHANGED: |
| case ui::AXEventGenerator::Event::SCROLL_VERTICAL_POSITION_CHANGED: |
| case ui::AXEventGenerator::Event::SELECTED_CHANGED: |
| case ui::AXEventGenerator::Event::SET_SIZE_CHANGED: |
| case ui::AXEventGenerator::Event::SORT_CHANGED: |
| case ui::AXEventGenerator::Event::STATE_CHANGED: |
| case ui::AXEventGenerator::Event::TEXT_ATTRIBUTE_CHANGED: |
| case ui::AXEventGenerator::Event::VALUE_MAX_CHANGED: |
| case ui::AXEventGenerator::Event::VALUE_MIN_CHANGED: |
| case ui::AXEventGenerator::Event::VALUE_STEP_CHANGED: |
| case ui::AXEventGenerator::Event::WIN_IACCESSIBLE_STATE_CHANGED: |
| // There are some notifications that aren't meaningful on Mac. |
| // It's okay to skip them. |
| break; |
| } |
| return events; |
| } |
| |
| void AccessibilityBridgeMacDelegate::DispatchAccessibilityAction(ui::AXNode::AXID target, |
| FlutterSemanticsAction action, |
| fml::MallocMapping 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:std::move(data)]; |
| } |
| |
| std::shared_ptr<FlutterPlatformNodeDelegate> |
| AccessibilityBridgeMacDelegate::CreateFlutterPlatformNodeDelegate() { |
| return std::make_shared<FlutterPlatformNodeDelegateMac>(flutter_engine_, view_controller_); |
| } |
| |
| // Private method |
| void AccessibilityBridgeMacDelegate::DispatchMacOSNotification( |
| gfx::NativeViewAccessible native_node, |
| NSAccessibilityNotificationName mac_notification) { |
| NSCAssert(mac_notification, @"The notification must not be null."); |
| NSCAssert(native_node, @"The notification target must not be null."); |
| NSAccessibilityPostNotification(native_node, mac_notification); |
| } |
| |
| void AccessibilityBridgeMacDelegate::DispatchMacOSNotificationWithUserInfo( |
| gfx::NativeViewAccessible native_node, |
| NSAccessibilityNotificationName mac_notification, |
| NSDictionary* user_info) { |
| NSCAssert(mac_notification, @"The notification must not be null."); |
| NSCAssert(native_node, @"The notification target must not be null."); |
| NSCAssert(user_info, @"The notification data must not be null."); |
| NSAccessibilityPostNotificationWithUserInfo(native_node, mac_notification, user_info); |
| } |
| |
| bool AccessibilityBridgeMacDelegate::HasPendingEvent(ui::AXEventGenerator::Event event) const { |
| NSCAssert(flutter_engine_, @"Flutter engine should not be deallocated"); |
| auto bridge = flutter_engine_.accessibilityBridge.lock(); |
| NSCAssert(bridge, @"Accessibility bridge in flutter engine must not be null."); |
| std::vector<ui::AXEventGenerator::TargetedEvent> pending_events = bridge->GetPendingEvents(); |
| for (const auto& pending_event : bridge->GetPendingEvents()) { |
| if (pending_event.event_params.event == event) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| } // namespace flutter |