diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h index 98ccb835489..7583a8d7aa0 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h @@ -322,6 +322,14 @@ FLUTTER_EXPORT */ @property(nonatomic, readonly) FlutterBasicMessageChannel* settingsChannel; +/** + * The `FlutterBasicMessageChannel` used for communicating key events + * from physical keyboards + * + * Can be nil after `destroyContext` is called. + */ +@property(nonatomic, readonly) FlutterBasicMessageChannel* keyEventChannel; + /** * The `NSURL` of the observatory for the service isolate. * diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 2c55925a2c4..d6d34cda536 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -81,6 +81,7 @@ static constexpr int kNumProfilerSamplesPerSec = 5; fml::scoped_nsobject _lifecycleChannel; fml::scoped_nsobject _systemChannel; fml::scoped_nsobject _settingsChannel; + fml::scoped_nsobject _keyEventChannel; int64_t _nextTextureId; @@ -350,6 +351,9 @@ static constexpr int kNumProfilerSamplesPerSec = 5; - (FlutterBasicMessageChannel*)settingsChannel { return _settingsChannel.get(); } +- (FlutterBasicMessageChannel*)keyEventChannel { + return _keyEventChannel.get(); +} - (NSURL*)observatoryUrl { return [_publisher.get() url]; @@ -364,6 +368,7 @@ static constexpr int kNumProfilerSamplesPerSec = 5; _lifecycleChannel.reset(); _systemChannel.reset(); _settingsChannel.reset(); + _keyEventChannel.reset(); } - (void)startProfiler:(NSString*)threadLabel { @@ -436,6 +441,11 @@ static constexpr int kNumProfilerSamplesPerSec = 5; binaryMessenger:self.binaryMessenger codec:[FlutterJSONMessageCodec sharedInstance]]); + _keyEventChannel.reset([[FlutterBasicMessageChannel alloc] + initWithName:@"flutter/keyevent" + binaryMessenger:self.binaryMessenger + codec:[FlutterJSONMessageCodec sharedInstance]]); + _textInputPlugin.reset([[FlutterTextInputPlugin alloc] init]); _textInputPlugin.get().textInputDelegate = self; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index ed7e1d79fd5..afd4db122dc 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -1024,6 +1024,58 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) [self updateViewportMetrics]; } +- (void)dispatchPresses:(NSSet*)presses API_AVAILABLE(ios(13.4)) { + if (@available(iOS 13.4, *)) { + for (UIPress* press in presses) { + if (press.key == nil || press.phase == UIPressPhaseStationary || + press.phase == UIPressPhaseChanged) { + continue; + } + NSMutableDictionary* keyMessage = [@{ + @"keymap" : @"ios", + @"type" : @"unknown", + @"keyCode" : @(press.key.keyCode), + @"modifiers" : @(press.key.modifierFlags), + @"characters" : press.key.characters, + @"charactersIgnoringModifiers" : press.key.charactersIgnoringModifiers + } mutableCopy]; + + if (press.phase == UIPressPhaseBegan) { + keyMessage[@"type"] = @"keydown"; + } else if (press.phase == UIPressPhaseEnded || press.phase == UIPressPhaseCancelled) { + keyMessage[@"type"] = @"keyup"; + } + + [[_engine.get() keyEventChannel] sendMessage:keyMessage]; + } + } +} + +- (void)pressesBegan:(NSSet*)presses withEvent:(UIEvent*)event API_AVAILABLE(ios(9.0)) { + if (@available(iOS 13.4, *)) { + [self dispatchPresses:presses]; + } +} + +- (void)pressesChanged:(NSSet*)presses withEvent:(UIEvent*)event API_AVAILABLE(ios(9.0)) { + if (@available(iOS 13.4, *)) { + [self dispatchPresses:presses]; + } +} + +- (void)pressesEnded:(NSSet*)presses withEvent:(UIEvent*)event API_AVAILABLE(ios(9.0)) { + if (@available(iOS 13.4, *)) { + [self dispatchPresses:presses]; + } +} + +- (void)pressesCancelled:(NSSet*)presses + withEvent:(UIEvent*)event API_AVAILABLE(ios(9.0)) { + if (@available(iOS 13.4, *)) { + [self dispatchPresses:presses]; + } +} + #pragma mark - Orientation updates - (void)onOrientationPreferencesUpdated:(NSNotification*)notification { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index f15ea376663..9f4348dd681 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -62,6 +62,7 @@ typedef enum UIAccessibilityContrast : NSInteger { @interface FlutterViewController (Tests) - (void)surfaceUpdated:(BOOL)appeared; - (void)performOrientationUpdate:(UIInterfaceOrientationMask)new_preferences; +- (void)dispatchPresses:(NSSet*)presses; @end @implementation FlutterViewControllerTest @@ -549,4 +550,150 @@ typedef enum UIAccessibilityContrast : NSInteger { OCMVerify([engine notifyLowMemory]); } +- (void)testValidKeyUpEvent API_AVAILABLE(ios(13.4)) { + if (@available(iOS 13.4, *)) { + // noop + } else { + return; + } + + id engine = OCMClassMock([FlutterEngine class]); + + id keyEventChannel = OCMClassMock([FlutterBasicMessageChannel class]); + OCMStub([engine keyEventChannel]).andReturn(keyEventChannel); + + FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + + id testSet = [self fakeUiPressSetForPhase:UIPressPhaseBegan + keyCode:UIKeyboardHIDUsageKeyboardA + modifierFlags:UIKeyModifierShift + characters:@"a" + charactersIgnoringModifiers:@"A"]; + + // Exercise behavior under test. + [vc dispatchPresses:testSet]; + + // Verify behavior. + OCMVerify([keyEventChannel + sendMessage:[OCMArg checkWithBlock:^BOOL(id message) { + return [message[@"keymap"] isEqualToString:@"ios"] && + [message[@"type"] isEqualToString:@"keydown"] && + [message[@"keyCode"] isEqualToNumber:[NSNumber numberWithInt:4]] && + [message[@"modifiers"] isEqualToNumber:[NSNumber numberWithInt:131072]] && + [message[@"characters"] isEqualToString:@"a"] && + [message[@"charactersIgnoringModifiers"] isEqualToString:@"A"]; + }]]); + + // Clean up mocks + [engine stopMocking]; + [keyEventChannel stopMocking]; +} + +- (void)testValidKeyDownEvent API_AVAILABLE(ios(13.4)) { + if (@available(iOS 13.4, *)) { + // noop + } else { + return; + } + + id engine = OCMClassMock([FlutterEngine class]); + + id keyEventChannel = OCMClassMock([FlutterBasicMessageChannel class]); + OCMStub([engine keyEventChannel]).andReturn(keyEventChannel); + + FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + + id testSet = [self fakeUiPressSetForPhase:UIPressPhaseEnded + keyCode:UIKeyboardHIDUsageKeyboardA + modifierFlags:UIKeyModifierShift + characters:@"a" + charactersIgnoringModifiers:@"A"]; + + // Exercise behavior under test. + [vc dispatchPresses:testSet]; + + // Verify behavior. + OCMVerify([keyEventChannel + sendMessage:[OCMArg checkWithBlock:^BOOL(id message) { + return [message[@"keymap"] isEqualToString:@"ios"] && + [message[@"type"] isEqualToString:@"keyup"] && + [message[@"keyCode"] isEqualToNumber:[NSNumber numberWithInt:4]] && + [message[@"modifiers"] isEqualToNumber:[NSNumber numberWithInt:131072]] && + [message[@"characters"] isEqualToString:@"a"] && + [message[@"charactersIgnoringModifiers"] isEqualToString:@"A"]; + }]]); + + // Clean up mocks + [engine stopMocking]; + [keyEventChannel stopMocking]; +} + +- (void)testIgnoredKeyEvents API_AVAILABLE(ios(13.4)) { + if (@available(iOS 13.4, *)) { + // noop + } else { + return; + } + + id engine = OCMClassMock([FlutterEngine class]); + + id keyEventChannel = OCMClassMock([FlutterBasicMessageChannel class]); + OCMStub([engine keyEventChannel]).andReturn(keyEventChannel); + + FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + + id emptySet = [NSSet set]; + id ignoredSet = [self fakeUiPressSetForPhase:UIPressPhaseStationary + keyCode:UIKeyboardHIDUsageKeyboardA + modifierFlags:UIKeyModifierShift + characters:@"a" + charactersIgnoringModifiers:@"A"]; + + id mockUiPress = OCMClassMock([UIPress class]); + OCMStub([mockUiPress phase]).andReturn(UIPressPhaseBegan); + id emptyKeySet = [NSSet setWithArray:@[ mockUiPress ]]; + // Exercise behavior under test. + [vc dispatchPresses:emptySet]; + [vc dispatchPresses:ignoredSet]; + [vc dispatchPresses:emptyKeySet]; + + // Verify behavior. + OCMVerify(never(), [keyEventChannel sendMessage:[OCMArg any]]); + + // Clean up mocks + [engine stopMocking]; + [keyEventChannel stopMocking]; +} + +- (NSSet*)fakeUiPressSetForPhase:(UIPressPhase)phase + keyCode:(UIKeyboardHIDUsage)keyCode + modifierFlags:(UIKeyModifierFlags)modifierFlags + characters:(NSString*)characters + charactersIgnoringModifiers:(NSString*)charactersIgnoringModifiers + API_AVAILABLE(ios(13.4)) { + if (@available(iOS 13.4, *)) { + // noop + } else { + return [NSSet set]; + } + id mockUiPress = OCMClassMock([UIPress class]); + OCMStub([mockUiPress phase]).andReturn(phase); + + id mockUiKey = OCMClassMock([UIKey class]); + OCMStub([mockUiKey keyCode]).andReturn(keyCode); + OCMStub([mockUiKey modifierFlags]).andReturn(modifierFlags); + OCMStub([mockUiKey characters]).andReturn(characters); + OCMStub([mockUiKey charactersIgnoringModifiers]).andReturn(charactersIgnoringModifiers); + + OCMStub([mockUiPress key]).andReturn(mockUiKey); + + return [NSSet setWithArray:@[ mockUiPress ]]; +} + @end