[macos] Synthesize modifier keys events on pointer events (#37870)

* [macos] Synthesize modifier keys events on pointer events

* Move test to FlutterViewControllerTest

* Simplify by using 'for in'

Co-authored-by: Bruno Leroux <bruno.leroux@gmail.com>
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.h b/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.h
index ab2b1c1..7a3b9ee 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.h
+++ b/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.h
@@ -30,4 +30,13 @@
  */
 - (nonnull instancetype)initWithSendEvent:(_Nonnull FlutterSendEmbedderKeyEvent)sendEvent;
 
+/**
+ * Synthesize modifier keys events.
+ *
+ * If needed, synthesize modifier keys up and down events by comparing their
+ * current pressing states with the given modifier flags.
+ */
+- (void)syncModifiersIfNeeded:(NSEventModifierFlags)modifierFlags
+                    timestamp:(NSTimeInterval)timestamp;
+
 @end
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.mm b/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.mm
index 5854bb0..4a5d14f 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.mm
@@ -780,6 +780,19 @@
   [_pendingResponses removeObjectForKey:@(responseId)];
 }
 
+- (void)syncModifiersIfNeeded:(NSEventModifierFlags)modifierFlags
+                    timestamp:(NSTimeInterval)timestamp {
+  FlutterAsyncKeyCallback replyCallback = ^(BOOL handled) {
+    // Do nothing.
+  };
+  FlutterKeyCallbackGuard* guardedCallback =
+      [[FlutterKeyCallbackGuard alloc] initWithCallback:replyCallback];
+  [self synchronizeModifiers:modifierFlags
+               ignoringFlags:0
+                   timestamp:timestamp
+                       guard:guardedCallback];
+}
+
 @end
 
 namespace {
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h
index c8ba505..64ca1a3 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h
+++ b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h
@@ -48,4 +48,13 @@
  */
 - (BOOL)isDispatchingKeyEvent:(nonnull NSEvent*)event;
 
+/**
+ * Synthesize modifier keys events.
+ *
+ * If needed, synthesize modifier keys up and down events by comparing their
+ * current pressing states with the given modifier flags.
+ */
+- (void)syncModifiersIfNeeded:(NSEventModifierFlags)modifierFlags
+                    timestamp:(NSTimeInterval)timestamp;
+
 @end
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm
index 116c945..78c18dd 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm
@@ -320,4 +320,12 @@
   }
 }
 
+- (void)syncModifiersIfNeeded:(NSEventModifierFlags)modifierFlags
+                    timestamp:(NSTimeInterval)timestamp {
+  // The embedder responder is the first element in _primaryResponders.
+  FlutterEmbedderKeyResponder* embedderResponder =
+      (FlutterEmbedderKeyResponder*)_primaryResponders[0];
+  [embedderResponder syncModifiersIfNeeded:modifierFlags timestamp:timestamp];
+}
+
 @end
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm b/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm
index 9034e8d..2c9e76e 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm
@@ -661,6 +661,8 @@
       flutterEvent.scroll_delta_y = scaledDeltaY;
     }
   }
+
+  [_keyboardManager syncModifiersIfNeeded:event.modifierFlags timestamp:event.timestamp];
   [_engine sendPointerEvent:flutterEvent];
 
   // Update tracking of state as reported to Flutter.
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/macos/framework/Source/FlutterViewControllerTest.mm
index 9030657..dbc9887 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterViewControllerTest.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterViewControllerTest.mm
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+#import "KeyCodeMap_Internal.h"
 #import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterViewController.h"
 #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h"
 
@@ -13,8 +14,27 @@
 #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h"
 #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.h"
 #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewControllerTestUtils.h"
+#include "flutter/shell/platform/embedder/test_utils/key_codes.g.h"
 #import "flutter/testing/testing.h"
 
+// A wrap to convert FlutterKeyEvent to a ObjC class.
+@interface KeyEventWrapper : NSObject
+@property(nonatomic) FlutterKeyEvent* data;
+- (nonnull instancetype)initWithEvent:(const FlutterKeyEvent*)event;
+@end
+
+@implementation KeyEventWrapper
+- (instancetype)initWithEvent:(const FlutterKeyEvent*)event {
+  self = [super init];
+  _data = new FlutterKeyEvent(*event);
+  return self;
+}
+
+- (void)dealloc {
+  delete _data;
+}
+@end
+
 @interface FlutterViewControllerTestObjC : NSObject
 - (bool)testKeyEventsAreSentToFramework;
 - (bool)testKeyEventsArePropagatedIfNotHandled;
@@ -22,6 +42,7 @@
 - (bool)testFlagsChangedEventsArePropagatedIfNotHandled;
 - (bool)testKeyboardIsRestartedOnEngineRestart;
 - (bool)testTrackpadGesturesAreSentToFramework;
+- (bool)testModifierKeysAreSynthesizedOnMouseMove;
 - (bool)testViewWillAppearCalledMultipleTimes;
 - (bool)testFlutterViewIsConfigured;
 
@@ -30,6 +51,8 @@
                         userData:(nullable void*)userData;
 @end
 
+using namespace ::flutter::testing::keycodes;
+
 namespace flutter::testing {
 
 namespace {
@@ -69,6 +92,19 @@
   OCMStub([mock flagsChanged:[OCMArg any]]).andDo(nil);
   return mock;
 }
+
+NSEvent* CreateMouseEvent(NSEventModifierFlags modifierFlags) {
+  return [NSEvent mouseEventWithType:NSEventTypeMouseMoved
+                            location:NSZeroPoint
+                       modifierFlags:modifierFlags
+                           timestamp:0
+                        windowNumber:0
+                             context:nil
+                         eventNumber:0
+                          clickCount:1
+                            pressure:1.0];
+}
+
 }  // namespace
 
 TEST(FlutterViewController, HasViewThatHidesOtherViewsInAccessibility) {
@@ -161,6 +197,10 @@
   ASSERT_TRUE([[FlutterViewControllerTestObjC alloc] testTrackpadGesturesAreSentToFramework]);
 }
 
+TEST(FlutterViewControllerTest, TestModifierKeysAreSynthesizedOnMouseMove) {
+  ASSERT_TRUE([[FlutterViewControllerTestObjC alloc] testModifierKeysAreSynthesizedOnMouseMove]);
+}
+
 TEST(FlutterViewControllerTest, testViewWillAppearCalledMultipleTimes) {
   ASSERT_TRUE([[FlutterViewControllerTestObjC alloc] testViewWillAppearCalledMultipleTimes]);
 }
@@ -763,4 +803,71 @@
   return true;
 }
 
+- (bool)testModifierKeysAreSynthesizedOnMouseMove {
+  id engineMock = OCMClassMock([FlutterEngine class]);
+  // Need to return a real renderer to allow view controller to load.
+  FlutterRenderer* renderer_ = [[FlutterRenderer alloc] initWithFlutterEngine:engineMock];
+  OCMStub([engineMock renderer]).andReturn(renderer_);
+
+  // Capture calls to sendKeyEvent
+  __block NSMutableArray<KeyEventWrapper*>* events =
+      [[NSMutableArray<KeyEventWrapper*> alloc] init];
+  OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {}
+                                                  callback:nil
+                                                  userData:nil])
+      .andDo((^(NSInvocation* invocation) {
+        FlutterKeyEvent* event;
+        [invocation getArgument:&event atIndex:2];
+        [events addObject:[[KeyEventWrapper alloc] initWithEvent:event]];
+      }));
+
+  FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
+                                                                                nibName:@""
+                                                                                 bundle:nil];
+  [viewController loadView];
+  [engineMock setViewController:viewController];
+  [viewController viewWillAppear];
+
+  // Zeroed modifier flag should not synthesize events.
+  NSEvent* mouseEvent = flutter::testing::CreateMouseEvent(0x00);
+  [viewController mouseMoved:mouseEvent];
+  EXPECT_EQ([events count], 0u);
+
+  // For each modifier key, check that key events are synthesized.
+  for (NSNumber* keyCode in flutter::keyCodeToModifierFlag) {
+    FlutterKeyEvent* event;
+    NSNumber* logicalKey;
+    NSNumber* physicalKey;
+    NSNumber* flag = flutter::keyCodeToModifierFlag[keyCode];
+
+    // Should synthesize down event.
+    NSEvent* mouseEvent = flutter::testing::CreateMouseEvent([flag unsignedLongValue]);
+    [viewController mouseMoved:mouseEvent];
+    EXPECT_EQ([events count], 1u);
+    event = events[0].data;
+    logicalKey = [flutter::keyCodeToLogicalKey objectForKey:keyCode];
+    physicalKey = [flutter::keyCodeToPhysicalKey objectForKey:keyCode];
+    EXPECT_EQ(event->type, kFlutterKeyEventTypeDown);
+    EXPECT_EQ(event->logical, logicalKey.unsignedLongLongValue);
+    EXPECT_EQ(event->physical, physicalKey.unsignedLongLongValue);
+    EXPECT_EQ(event->synthesized, true);
+
+    // Should synthesize up event.
+    mouseEvent = flutter::testing::CreateMouseEvent(0x00);
+    [viewController mouseMoved:mouseEvent];
+    EXPECT_EQ([events count], 2u);
+    event = events[1].data;
+    logicalKey = [flutter::keyCodeToLogicalKey objectForKey:keyCode];
+    physicalKey = [flutter::keyCodeToPhysicalKey objectForKey:keyCode];
+    EXPECT_EQ(event->type, kFlutterKeyEventTypeUp);
+    EXPECT_EQ(event->logical, logicalKey.unsignedLongLongValue);
+    EXPECT_EQ(event->physical, physicalKey.unsignedLongLongValue);
+    EXPECT_EQ(event->synthesized, true);
+
+    [events removeAllObjects];
+  };
+
+  return true;
+}
+
 @end