diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 92cbe68b196f2..0095ff65e9fd9 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -1356,16 +1356,29 @@ void updateSemantics(@NonNull ByteBuffer buffer, @NonNull String[] strings) { // Dispatch a TYPE_WINDOW_STATE_CHANGED event if the most recent route id changed from the // previously cached route id. + + // Finds the last route that is not in the previous routes. SemanticsNode lastAdded = null; for (SemanticsNode semanticsNode : newRoutes) { if (!flutterNavigationStack.contains(semanticsNode.id)) { lastAdded = semanticsNode; } } + + // If all the routes are in the previous route, get the last route. if (lastAdded == null && newRoutes.size() > 0) { lastAdded = newRoutes.get(newRoutes.size() - 1); } - if (lastAdded != null && lastAdded.id != previousRouteId) { + + // There are two cases if lastAdded != nil + // 1. lastAdded is not in previous routes. In this case, + // lastAdded.id != previousRouteId + // 2. All new routes are in previous routes and + // lastAdded = newRoutes.last. + // In the first case, we need to announce new route. In the second case, + // we need to announce if one list is shorter than the other. + if (lastAdded != null + && (lastAdded.id != previousRouteId || newRoutes.size() != flutterNavigationStack.size())) { previousRouteId = lastAdded.id; sendWindowChangeEvent(lastAdded); } diff --git a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index 673c1e48c95eb..f035b9f12eb32 100644 --- a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -142,6 +142,134 @@ public void itUnfocusesPlatformViewWhenPlatformViewGoesAway() { assertEquals(event.getEventType(), AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); } + @Test + public void itAnnouncesRouteNameWhenAddingNewRoute() { + AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class); + AccessibilityManager mockManager = mock(AccessibilityManager.class); + View mockRootView = mock(View.class); + Context context = mock(Context.class); + when(mockRootView.getContext()).thenReturn(context); + when(context.getPackageName()).thenReturn("test"); + AccessibilityBridge accessibilityBridge = + setUpBridge(mockRootView, mockManager, mockViewEmbedder); + ViewParent mockParent = mock(ViewParent.class); + when(mockRootView.getParent()).thenReturn(mockParent); + when(mockManager.isEnabled()).thenReturn(true); + + TestSemanticsNode root = new TestSemanticsNode(); + root.id = 0; + TestSemanticsNode node1 = new TestSemanticsNode(); + node1.id = 1; + node1.addFlag(AccessibilityBridge.Flag.SCOPES_ROUTE); + node1.addFlag(AccessibilityBridge.Flag.NAMES_ROUTE); + node1.label = "node1"; + root.children.add(node1); + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(AccessibilityEvent.class); + verify(mockParent, times(2)) + .requestSendAccessibilityEvent(eq(mockRootView), eventCaptor.capture()); + AccessibilityEvent event = eventCaptor.getAllValues().get(0); + assertEquals(event.getEventType(), AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + List sentences = event.getText(); + assertEquals(sentences.size(), 1); + assertEquals(sentences.get(0).toString(), "node1"); + + TestSemanticsNode new_root = new TestSemanticsNode(); + new_root.id = 0; + TestSemanticsNode new_node1 = new TestSemanticsNode(); + new_node1.id = 1; + new_node1.addFlag(AccessibilityBridge.Flag.SCOPES_ROUTE); + new_node1.addFlag(AccessibilityBridge.Flag.NAMES_ROUTE); + new_node1.label = "new_node1"; + new_root.children.add(new_node1); + TestSemanticsNode new_node2 = new TestSemanticsNode(); + new_node2.id = 2; + new_node2.addFlag(AccessibilityBridge.Flag.SCOPES_ROUTE); + new_node2.addFlag(AccessibilityBridge.Flag.NAMES_ROUTE); + new_node2.label = "new_node2"; + new_node1.children.add(new_node2); + testSemanticsUpdate = new_root.toUpdate(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + + eventCaptor = ArgumentCaptor.forClass(AccessibilityEvent.class); + verify(mockParent, times(4)) + .requestSendAccessibilityEvent(eq(mockRootView), eventCaptor.capture()); + event = eventCaptor.getAllValues().get(2); + assertEquals(event.getEventType(), AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + sentences = event.getText(); + assertEquals(sentences.size(), 1); + assertEquals(sentences.get(0).toString(), "new_node2"); + } + + @Test + public void itAnnouncesRouteNameWhenRemoveARoute() { + AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class); + AccessibilityManager mockManager = mock(AccessibilityManager.class); + View mockRootView = mock(View.class); + Context context = mock(Context.class); + when(mockRootView.getContext()).thenReturn(context); + when(context.getPackageName()).thenReturn("test"); + AccessibilityBridge accessibilityBridge = + setUpBridge(mockRootView, mockManager, mockViewEmbedder); + ViewParent mockParent = mock(ViewParent.class); + when(mockRootView.getParent()).thenReturn(mockParent); + when(mockManager.isEnabled()).thenReturn(true); + + TestSemanticsNode root = new TestSemanticsNode(); + root.id = 0; + TestSemanticsNode node1 = new TestSemanticsNode(); + node1.id = 1; + node1.addFlag(AccessibilityBridge.Flag.SCOPES_ROUTE); + node1.addFlag(AccessibilityBridge.Flag.NAMES_ROUTE); + node1.label = "node1"; + root.children.add(node1); + TestSemanticsNode node2 = new TestSemanticsNode(); + node2.id = 2; + node2.addFlag(AccessibilityBridge.Flag.SCOPES_ROUTE); + node2.addFlag(AccessibilityBridge.Flag.NAMES_ROUTE); + node2.label = "node2"; + node1.children.add(node2); + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(AccessibilityEvent.class); + verify(mockParent, times(2)) + .requestSendAccessibilityEvent(eq(mockRootView), eventCaptor.capture()); + AccessibilityEvent event = eventCaptor.getAllValues().get(0); + assertEquals(event.getEventType(), AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + List sentences = event.getText(); + assertEquals(sentences.size(), 1); + assertEquals(sentences.get(0).toString(), "node2"); + + TestSemanticsNode new_root = new TestSemanticsNode(); + new_root.id = 0; + TestSemanticsNode new_node1 = new TestSemanticsNode(); + new_node1.id = 1; + new_node1.label = "new_node1"; + new_root.children.add(new_node1); + TestSemanticsNode new_node2 = new TestSemanticsNode(); + new_node2.id = 2; + new_node2.addFlag(AccessibilityBridge.Flag.SCOPES_ROUTE); + new_node2.addFlag(AccessibilityBridge.Flag.NAMES_ROUTE); + new_node2.label = "new_node2"; + new_node1.children.add(new_node2); + testSemanticsUpdate = new_root.toUpdate(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + + eventCaptor = ArgumentCaptor.forClass(AccessibilityEvent.class); + verify(mockParent, times(4)) + .requestSendAccessibilityEvent(eq(mockRootView), eventCaptor.capture()); + event = eventCaptor.getAllValues().get(2); + assertEquals(event.getEventType(), AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + sentences = event.getText(); + assertEquals(sentences.size(), 1); + assertEquals(sentences.get(0).toString(), "new_node2"); + } + @Test public void itAnnouncesWhiteSpaceWhenNoNamesRoute() { AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class); diff --git a/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm b/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm index 33ea01d1881fa..e5b19fadd91cf 100644 --- a/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm +++ b/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm @@ -167,17 +167,27 @@ void PostAccessibilityNotification(UIAccessibilityNotifications notification, } NSMutableArray* newRoutes = [[[NSMutableArray alloc] init] autorelease]; [root collectRoutes:newRoutes]; + // Finds the last route that is not in the previous routes. for (SemanticsObject* route in newRoutes) { - if (std::find(previous_routes_.begin(), previous_routes_.end(), [route uid]) != + if (std::find(previous_routes_.begin(), previous_routes_.end(), [route uid]) == previous_routes_.end()) { lastAdded = route; } } + // If all the routes are in the previous route, get the last route. if (lastAdded == nil && [newRoutes count] > 0) { int index = [newRoutes count] - 1; lastAdded = [newRoutes objectAtIndex:index]; } - if (lastAdded != nil && [lastAdded uid] != previous_route_id_) { + // There are two cases if lastAdded != nil + // 1. lastAdded is not in previous routes. In this case, + // [lastAdded uid] != previous_route_id_ + // 2. All new routes are in previous routes and + // lastAdded = newRoutes.last. + // In the first case, we need to announce new route. In the second case, + // we need to announce if one list is shorter than the other. + if (lastAdded != nil && + ([lastAdded uid] != previous_route_id_ || [newRoutes count] != previous_routes_.size())) { previous_route_id_ = [lastAdded uid]; routeChanged = true; } 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 41970e67c8f73..bc6a6ebdc01ab 100644 --- a/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm +++ b/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm @@ -333,6 +333,178 @@ - (void)testAnnouncesRouteChanges { UIAccessibilityScreenChangedNotification); } +- (void)testAnnouncesRouteChangesWhenAddAdditionalRoute { + 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, + /*task_runners=*/runners); + 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)); + + flutter::CustomAccessibilityActionUpdates actions; + flutter::SemanticsNodeUpdates nodes; + + flutter::SemanticsNode node1; + node1.id = 1; + node1.label = "node1"; + node1.flags = static_cast(flutter::SemanticsFlags::kScopesRoute) | + static_cast(flutter::SemanticsFlags::kNamesRoute); + nodes[node1.id] = node1; + flutter::SemanticsNode root_node; + root_node.id = kRootNodeId; + root_node.flags = static_cast(flutter::SemanticsFlags::kScopesRoute); + root_node.childrenInTraversalOrder = {1}; + root_node.childrenInHitTestOrder = {1}; + nodes[root_node.id] = root_node; + bridge->UpdateSemantics(/*nodes=*/nodes, /*actions=*/actions); + + XCTAssertEqual([accessibility_notifications count], 1ul); + XCTAssertEqualObjects(accessibility_notifications[0][@"argument"], @"node1"); + XCTAssertEqual([accessibility_notifications[0][@"notification"] unsignedIntValue], + UIAccessibilityScreenChangedNotification); + + flutter::SemanticsNodeUpdates new_nodes; + + flutter::SemanticsNode new_node1; + new_node1.id = 1; + new_node1.label = "new_node1"; + new_node1.flags = static_cast(flutter::SemanticsFlags::kScopesRoute) | + static_cast(flutter::SemanticsFlags::kNamesRoute); + new_node1.childrenInTraversalOrder = {2}; + new_node1.childrenInHitTestOrder = {2}; + new_nodes[new_node1.id] = new_node1; + flutter::SemanticsNode new_node2; + new_node2.id = 2; + new_node2.label = "new_node2"; + new_node2.flags = static_cast(flutter::SemanticsFlags::kScopesRoute) | + static_cast(flutter::SemanticsFlags::kNamesRoute); + new_nodes[new_node2.id] = new_node2; + flutter::SemanticsNode new_root_node; + new_root_node.id = kRootNodeId; + new_root_node.flags = static_cast(flutter::SemanticsFlags::kScopesRoute); + new_root_node.childrenInTraversalOrder = {1}; + new_root_node.childrenInHitTestOrder = {1}; + new_nodes[new_root_node.id] = new_root_node; + bridge->UpdateSemantics(/*nodes=*/new_nodes, /*actions=*/actions); + XCTAssertEqual([accessibility_notifications count], 2ul); + XCTAssertEqualObjects(accessibility_notifications[1][@"argument"], @"new_node2"); + XCTAssertEqual([accessibility_notifications[1][@"notification"] unsignedIntValue], + UIAccessibilityScreenChangedNotification); +} + +- (void)testAnnouncesRouteChangesRemoveRouteInMiddle { + 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, + /*task_runners=*/runners); + 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)); + + flutter::CustomAccessibilityActionUpdates actions; + flutter::SemanticsNodeUpdates nodes; + + flutter::SemanticsNode node1; + node1.id = 1; + node1.label = "node1"; + node1.flags = static_cast(flutter::SemanticsFlags::kScopesRoute) | + static_cast(flutter::SemanticsFlags::kNamesRoute); + node1.childrenInTraversalOrder = {2}; + node1.childrenInHitTestOrder = {2}; + nodes[node1.id] = node1; + flutter::SemanticsNode node2; + node2.id = 2; + node2.label = "node2"; + node2.flags = static_cast(flutter::SemanticsFlags::kScopesRoute) | + static_cast(flutter::SemanticsFlags::kNamesRoute); + nodes[node2.id] = node2; + flutter::SemanticsNode root_node; + root_node.id = kRootNodeId; + root_node.flags = static_cast(flutter::SemanticsFlags::kScopesRoute); + root_node.childrenInTraversalOrder = {1}; + root_node.childrenInHitTestOrder = {1}; + nodes[root_node.id] = root_node; + bridge->UpdateSemantics(/*nodes=*/nodes, /*actions=*/actions); + + XCTAssertEqual([accessibility_notifications count], 1ul); + XCTAssertEqualObjects(accessibility_notifications[0][@"argument"], @"node2"); + XCTAssertEqual([accessibility_notifications[0][@"notification"] unsignedIntValue], + UIAccessibilityScreenChangedNotification); + + flutter::SemanticsNodeUpdates new_nodes; + + flutter::SemanticsNode new_node1; + new_node1.id = 1; + new_node1.label = "new_node1"; + new_node1.childrenInTraversalOrder = {2}; + new_node1.childrenInHitTestOrder = {2}; + new_nodes[new_node1.id] = new_node1; + flutter::SemanticsNode new_node2; + new_node2.id = 2; + new_node2.label = "new_node2"; + new_node2.flags = static_cast(flutter::SemanticsFlags::kScopesRoute) | + static_cast(flutter::SemanticsFlags::kNamesRoute); + new_nodes[new_node2.id] = new_node2; + flutter::SemanticsNode new_root_node; + new_root_node.id = kRootNodeId; + new_root_node.flags = static_cast(flutter::SemanticsFlags::kScopesRoute); + new_root_node.childrenInTraversalOrder = {1}; + new_root_node.childrenInHitTestOrder = {1}; + new_nodes[new_root_node.id] = new_root_node; + bridge->UpdateSemantics(/*nodes=*/new_nodes, /*actions=*/actions); + XCTAssertEqual([accessibility_notifications count], 2ul); + XCTAssertEqualObjects(accessibility_notifications[1][@"argument"], @"new_node2"); + XCTAssertEqual([accessibility_notifications[1][@"notification"] unsignedIntValue], + UIAccessibilityScreenChangedNotification); +} + - (void)testAnnouncesRouteChangesWhenNoNamesRoute { flutter::MockDelegate mock_delegate; auto thread_task_runner = CreateNewThread("AccessibilityBridgeTest");