diff --git a/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h b/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h index 98ccb8354896e..7583a8d7aa04d 100644 --- a/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h +++ b/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/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 2c55925a2c4d5..d6d34cda5364b 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -81,6 +81,7 @@ @implementation FlutterEngine { fml::scoped_nsobject _lifecycleChannel; fml::scoped_nsobject _systemChannel; fml::scoped_nsobject _settingsChannel; + fml::scoped_nsobject _keyEventChannel; int64_t _nextTextureId; @@ -350,6 +351,9 @@ - (FlutterBasicMessageChannel*)systemChannel { - (FlutterBasicMessageChannel*)settingsChannel { return _settingsChannel.get(); } +- (FlutterBasicMessageChannel*)keyEventChannel { + return _keyEventChannel.get(); +} - (NSURL*)observatoryUrl { return [_publisher.get() url]; @@ -364,6 +368,7 @@ - (void)resetChannels { _lifecycleChannel.reset(); _systemChannel.reset(); _settingsChannel.reset(); + _keyEventChannel.reset(); } - (void)startProfiler:(NSString*)threadLabel { @@ -436,6 +441,11 @@ - (void)setupChannels { 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/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index ed7e1d79fd56d..afd4db122dcbd 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -1024,6 +1024,58 @@ - (void)keyboardWillBeHidden:(NSNotification*)notification { [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/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index f15ea37666319..9f4348dd6812f 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -62,6 +62,7 @@ - (UIAccessibilityContrast)accessibilityContrast; @interface FlutterViewController (Tests) - (void)surfaceUpdated:(BOOL)appeared; - (void)performOrientationUpdate:(UIInterfaceOrientationMask)new_preferences; +- (void)dispatchPresses:(NSSet*)presses; @end @implementation FlutterViewControllerTest @@ -549,4 +550,150 @@ - (void)testNotifyLowMemory { 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