[macOS, Keyboard] Enqueue event processing (#32120)
* Impl
Remove log
add sync; clean code
Format
* Docs
* Tests
* Apply suggestions from code review
Co-authored-by: Greg Spencer <gspencergoog@users.noreply.github.com>
Co-authored-by: Greg Spencer <gspencergoog@users.noreply.github.com>
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.mm b/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.mm
index 101dfe2..905d6fb 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterEmbedderKeyResponder.mm
@@ -3,6 +3,7 @@
// found in the LICENSE file.
#import <objc/message.h>
+#include <memory>
#import "FlutterEmbedderKeyResponder.h"
#import "KeyCodeMap_Internal.h"
@@ -248,40 +249,11 @@
/**
* The invocation context for |HandleResponse|, wrapping
* |FlutterEmbedderKeyResponder.handleResponse|.
- *
- * The embedder functions only accept C-functions as callbacks, as well as an
- * arbitrary user_data. In order to send an instance method of
- * |FlutterEmbedderKeyResponder.handleResponse| to the engine's |SendKeyEvent|,
- * the embedder wraps the invocation into a C-function |HandleResponse| and
- * invocation context |FlutterKeyPendingResponse|.
- *
- * When this object is sent to the engine's |SendKeyEvent| as |user_data|, it
- * must be attached with |__bridge_retained|. When this object is parsed
- * in |HandleResponse| from |user_data|, it will be attached with
- * |__bridge_transfer|.
*/
-@interface FlutterKeyPendingResponse : NSObject
-
-@property(nonatomic) FlutterEmbedderKeyResponder* responder;
-
-@property(nonatomic) uint64_t responseId;
-
-- (nonnull instancetype)initWithHandler:(nonnull FlutterEmbedderKeyResponder*)responder
- responseId:(uint64_t)responseId;
-
-@end
-
-@implementation FlutterKeyPendingResponse
-- (instancetype)initWithHandler:(FlutterEmbedderKeyResponder*)responder
- responseId:(uint64_t)responseId {
- self = [super init];
- if (self != nil) {
- _responder = responder;
- _responseId = responseId;
- }
- return self;
-}
-@end
+struct FlutterKeyPendingResponse {
+ FlutterEmbedderKeyResponder* responder;
+ uint64_t responseId;
+};
/**
* Guards a |FlutterAsyncKeyCallback| to make sure it's handled exactly once
@@ -598,11 +570,10 @@
callback:(FlutterKeyCallbackGuard*)callback {
_responseId += 1;
uint64_t responseId = _responseId;
- FlutterKeyPendingResponse* pending =
- [[FlutterKeyPendingResponse alloc] initWithHandler:self responseId:responseId];
+ // The `pending` is released in `HandleResponse`.
+ FlutterKeyPendingResponse* pending = new FlutterKeyPendingResponse{self, responseId};
[callback pendTo:_pendingResponses withId:responseId];
- // The `__bridge_retained` here is matched by `__bridge_transfer` in HandleResponse.
- _sendEvent(event, HandleResponse, (__bridge_retained void*)pending);
+ _sendEvent(event, HandleResponse, pending);
callback.sentAnyEvents = TRUE;
}
@@ -809,8 +780,9 @@
namespace {
void HandleResponse(bool handled, void* user_data) {
- // The `__bridge_transfer` here is matched by `__bridge_retained` in sendPrimaryFlutterEvent.
- FlutterKeyPendingResponse* pending = (__bridge_transfer FlutterKeyPendingResponse*)user_data;
- [pending.responder handleResponse:handled forId:pending.responseId];
+ // Use unique_ptr to release on leaving.
+ auto pending = std::unique_ptr<FlutterKeyPendingResponse>(
+ reinterpret_cast<FlutterKeyPendingResponse*>(user_data));
+ [pending->responder handleResponse:handled forId:pending->responseId];
}
} // namespace
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h
index af723a7..7c6b037 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h
+++ b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h
@@ -4,17 +4,8 @@
#import <Cocoa/Cocoa.h>
-#import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterEngine.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterKeyboardViewDelegate.h"
-namespace {
-// Someohow this pointer type must be defined as a single type for the compiler
-// to compile the function pointer type (due to _Nullable).
-typedef NSResponder* _NSResponderPtr;
-}
-
-typedef _Nullable _NSResponderPtr (^NextResponderProvider)();
-
/**
* Processes keyboard events and cooperate with |TextInputPlugin|.
*
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm
index c240682..65227b4 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm
@@ -9,6 +9,15 @@
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterKeyPrimaryResponder.h"
+namespace {
+typedef void (^VoidBlock)();
+
+// Someohow this pointer type must be defined as a single type for the compiler
+// to compile the function pointer type (due to _Nullable).
+typedef NSResponder* _NSResponderPtr;
+typedef _Nullable _NSResponderPtr (^NextResponderProvider)();
+}
+
@interface FlutterKeyboardManager ()
/**
@@ -21,13 +30,39 @@
*/
@property(nonatomic) NSMutableArray<id<FlutterKeyPrimaryResponder>>* primaryResponders;
+@property(nonatomic) NSMutableArray<NSEvent*>* pendingTextEvents;
+
+@property(nonatomic) BOOL processingEvent;
+
/**
* Add a primary responder, which asynchronously decides whether to handle an
* event.
*/
- (void)addPrimaryResponder:(nonnull id<FlutterKeyPrimaryResponder>)responder;
-- (void)dispatchToSecondaryResponders:(NSEvent*)event;
+/**
+ * Start processing the next event if not started already.
+ *
+ * This function might initiate an async process, whose callback calls this
+ * function again.
+ */
+- (void)processNextEvent;
+
+/**
+ * Implement how to process an event.
+ *
+ * The `onFinish` must be called eventually, either during this function or
+ * asynchronously later, otherwise the event queue will be stuck.
+ *
+ * This function is called by processNextEvent.
+ */
+- (void)performProcessEvent:(NSEvent*)event onFinish:(nonnull VoidBlock)onFinish;
+
+/**
+ * Dispatch an event that's not hadled by the responders to text input plugin,
+ * and potentially to the next responder.
+ */
+- (void)dispatchTextEvent:(nonnull NSEvent*)pendingEvent;
@end
@@ -38,6 +73,7 @@
- (nonnull instancetype)initWithViewDelegate:(nonnull id<FlutterKeyboardViewDelegate>)viewDelegate {
self = [super init];
if (self != nil) {
+ _processingEvent = FALSE;
_viewDelegate = viewDelegate;
_primaryResponders = [[NSMutableArray alloc] init];
@@ -57,6 +93,7 @@
getBinaryMessenger]
codec:[FlutterJSONMessageCodec
sharedInstance]]]];
+ _pendingTextEvents = [[NSMutableArray alloc] init];
}
return self;
}
@@ -66,6 +103,9 @@
}
- (void)handleEvent:(nonnull NSEvent*)event {
+ // The `handleEvent` does not process the event immediately, but instead put
+ // events into a queue. Events are processed one by one by `processNextEvent`.
+
// Be sure to add a handling method in propagateKeyEvent when allowing more
// event types here.
if (event.type != NSEventTypeKeyDown && event.type != NSEventTypeKeyUp &&
@@ -73,8 +113,35 @@
return;
}
+ [_pendingTextEvents addObject:event];
+ [self processNextEvent];
+}
+
+#pragma mark - Private
+
+- (void)processNextEvent {
+ @synchronized(self) {
+ if (_processingEvent || [_pendingTextEvents count] == 0) {
+ return;
+ }
+ _processingEvent = TRUE;
+ }
+
+ NSEvent* pendingEvent = [_pendingTextEvents firstObject];
+ [_pendingTextEvents removeObjectAtIndex:0];
+
+ __weak __typeof__(self) weakSelf = self;
+ VoidBlock onFinish = ^() {
+ weakSelf.processingEvent = FALSE;
+ [weakSelf processNextEvent];
+ };
+ [self performProcessEvent:pendingEvent onFinish:onFinish];
+}
+
+- (void)performProcessEvent:(NSEvent*)event onFinish:(VoidBlock)onFinish {
if (_viewDelegate.isComposing) {
- [self dispatchToSecondaryResponders:event];
+ [self dispatchTextEvent:event];
+ onFinish();
return;
}
@@ -86,12 +153,16 @@
__weak __typeof__(self) weakSelf = self;
__block int unreplied = [_primaryResponders count];
__block BOOL anyHandled = false;
+
FlutterAsyncKeyCallback replyCallback = ^(BOOL handled) {
unreplied -= 1;
NSAssert(unreplied >= 0, @"More primary responders replied than possible.");
anyHandled = anyHandled || handled;
- if (unreplied == 0 && !anyHandled) {
- [weakSelf dispatchToSecondaryResponders:event];
+ if (unreplied == 0) {
+ if (!anyHandled) {
+ [weakSelf dispatchTextEvent:event];
+ }
+ onFinish();
}
};
@@ -100,9 +171,7 @@
}
}
-#pragma mark - Private
-
-- (void)dispatchToSecondaryResponders:(NSEvent*)event {
+- (void)dispatchTextEvent:(NSEvent*)event {
if ([_viewDelegate onTextInputKeyEvent:event]) {
return;
}
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManagerUnittests.mm b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManagerUnittests.mm
index 9ee515d..14446ac 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManagerUnittests.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManagerUnittests.mm
@@ -14,18 +14,32 @@
namespace {
typedef BOOL (^BoolGetter)();
-typedef void (^AsyncKeyCallback)(BOOL handled);
-typedef void (^AsyncKeyCallbackHandler)(AsyncKeyCallback callback);
+typedef void (^AsyncKeyCallbackHandler)(FlutterAsyncKeyCallback callback);
+typedef BOOL (^TextInputCallback)(NSEvent*);
-NSEvent* keyDownEvent(unsigned short keyCode) {
+// When the Vietnamese IME converts messages into "pure text" messages, their
+// key codes are set to "empty".
+//
+// The 0 also happens to be the key code for key A.
+constexpr uint16_t kKeyCodeEmpty = 0x00;
+
+constexpr uint16_t kKeyCodeKeyO = 0x1f;
+constexpr uint16_t kKeyCodeBackspace = 0x33;
+
+// Constants used for `recordCallTypesTo:forTypes:`.
+constexpr uint32_t kEmbedderCall = 0x1;
+constexpr uint32_t kChannelCall = 0x2;
+constexpr uint32_t kTextCall = 0x4;
+
+NSEvent* keyDownEvent(unsigned short keyCode, NSString* chars = @"", NSString* charsUnmod = @"") {
return [NSEvent keyEventWithType:NSEventTypeKeyDown
location:NSZeroPoint
modifierFlags:0x100
timestamp:0
windowNumber:0
context:nil
- characters:@""
- charactersIgnoringModifiers:@""
+ characters:chars
+ charactersIgnoringModifiers:charsUnmod
isARepeat:NO
keyCode:keyCode];
}
@@ -68,11 +82,36 @@
@interface KeyboardTester : NSObject
- (nonnull instancetype)init;
+
+// Set embedder calls to respond immediately with the given response.
- (void)respondEmbedderCallsWith:(BOOL)response;
+
+// Record embedder calls to the given storage.
+//
+// They are not responded to until the stored callbacks are manually called.
- (void)recordEmbedderCallsTo:(nonnull NSMutableArray<FlutterAsyncKeyCallback>*)storage;
+
+// Set channel calls to respond immediately with the given response.
- (void)respondChannelCallsWith:(BOOL)response;
+
+// Record channel calls to the given storage.
+//
+// They are not responded to until the stored callbacks are manually called.
- (void)recordChannelCallsTo:(nonnull NSMutableArray<FlutterAsyncKeyCallback>*)storage;
+// Set text calls to respond with the given response.
+- (void)respondTextInputWith:(BOOL)response;
+
+// At the start of any kind of call, record the call type to the given storage.
+//
+// Only calls that are included in `typeMask` will be added. Options are
+// kEmbedderCall, kChannelCall, and kTextCall.
+//
+// This method does not conflict with other call settings, and the recording
+// takes place before the callbacks are (or are not) invoked.
+- (void)recordCallTypesTo:(nonnull NSMutableArray<NSNumber*>*)typeStorage
+ forTypes:(uint32_t)typeMask;
+
@property(nonatomic) FlutterKeyboardManager* manager;
@property(nonatomic) NSResponder* nextResponder;
@property(nonatomic, assign) BOOL isComposing;
@@ -93,7 +132,10 @@
@implementation KeyboardTester {
AsyncKeyCallbackHandler _embedderHandler;
AsyncKeyCallbackHandler _channelHandler;
- BOOL _textInputResponse;
+ TextInputCallback _textCallback;
+
+ NSMutableArray<NSNumber*>* _typeStorage;
+ uint32_t _typeStorageMask;
}
- (nonnull instancetype)init {
@@ -129,31 +171,39 @@
}
- (void)respondEmbedderCallsWith:(BOOL)response {
- _embedderHandler = ^(AsyncKeyCallback callback) {
+ _embedderHandler = ^(FlutterAsyncKeyCallback callback) {
callback(response);
};
}
- (void)recordEmbedderCallsTo:(nonnull NSMutableArray<FlutterAsyncKeyCallback>*)storage {
- _embedderHandler = ^(AsyncKeyCallback callback) {
+ _embedderHandler = ^(FlutterAsyncKeyCallback callback) {
[storage addObject:callback];
};
}
- (void)respondChannelCallsWith:(BOOL)response {
- _channelHandler = ^(AsyncKeyCallback callback) {
+ _channelHandler = ^(FlutterAsyncKeyCallback callback) {
callback(response);
};
}
- (void)recordChannelCallsTo:(nonnull NSMutableArray<FlutterAsyncKeyCallback>*)storage {
- _channelHandler = ^(AsyncKeyCallback callback) {
+ _channelHandler = ^(FlutterAsyncKeyCallback callback) {
[storage addObject:callback];
};
}
- (void)respondTextInputWith:(BOOL)response {
- _textInputResponse = response;
+ _textCallback = ^(NSEvent* event) {
+ return response;
+ };
+}
+
+- (void)recordCallTypesTo:(nonnull NSMutableArray<NSNumber*>*)typeStorage
+ forTypes:(uint32_t)typeMask {
+ _typeStorage = typeStorage;
+ _typeStorageMask = typeMask;
}
#pragma mark - Private
@@ -161,6 +211,9 @@
- (void)handleEmbedderEvent:(const FlutterKeyEvent&)event
callback:(nullable FlutterKeyEventCallback)callback
userData:(nullable void*)userData {
+ if (_typeStorage != nil && (_typeStorageMask & kEmbedderCall) != 0) {
+ [_typeStorage addObject:@(kEmbedderCall)];
+ }
if (callback != nullptr) {
_embedderHandler(^(BOOL handled) {
callback(handled, userData);
@@ -171,6 +224,9 @@
- (void)handleChannelMessage:(NSString*)channel
message:(NSData* _Nullable)message
binaryReply:(FlutterBinaryReply _Nullable)callback {
+ if (_typeStorage != nil && (_typeStorageMask & kChannelCall) != 0) {
+ [_typeStorage addObject:@(kChannelCall)];
+ }
_channelHandler(^(BOOL handled) {
NSDictionary* result = @{
@"handled" : @(handled),
@@ -181,7 +237,10 @@
}
- (BOOL)handleTextInputKeyEvent:(NSEvent*)event {
- return _textInputResponse;
+ if (_typeStorage != nil && (_typeStorageMask & kTextCall) != 0) {
+ [_typeStorage addObject:@(kTextCall)];
+ }
+ return _textCallback(event);
}
@end
@@ -193,6 +252,7 @@
- (bool)textInputPlugin;
- (bool)forwardKeyEventsToSystemWhenComposing;
- (bool)emptyNextResponder;
+- (bool)racingConditionBetweenKeyAndText;
@end
namespace flutter::testing {
@@ -220,6 +280,10 @@
ASSERT_TRUE([[FlutterKeyboardManagerUnittestsObjC alloc] emptyNextResponder]);
}
+TEST(FlutterKeyboardManagerUnittests, RacingConditionBetweenKeyAndText) {
+ ASSERT_TRUE([[FlutterKeyboardManagerUnittestsObjC alloc] racingConditionBetweenKeyAndText]);
+}
+
} // namespace flutter::testing
@implementation FlutterKeyboardManagerUnittestsObjC
@@ -402,4 +466,80 @@
return true;
}
+// Regression test for https://github.com/flutter/flutter/issues/82673.
+- (bool)racingConditionBetweenKeyAndText {
+ KeyboardTester* tester = [[KeyboardTester alloc] init];
+
+ // Use Vietnamese IME (GoTiengViet, Telex mode) to type "uco".
+
+ // The events received by the framework. The engine might receive
+ // a channel message "setEditingState" from the framework.
+ NSMutableArray<FlutterAsyncKeyCallback>* keyCallbacks =
+ [NSMutableArray<FlutterAsyncKeyCallback> array];
+ [tester recordEmbedderCallsTo:keyCallbacks];
+
+ NSMutableArray<NSNumber*>* allCalls = [NSMutableArray<NSNumber*> array];
+ [tester recordCallTypesTo:allCalls forTypes:(kEmbedderCall | kTextCall)];
+
+ // Tap key U, which is converted by IME into a pure text message "ư".
+
+ [tester.manager handleEvent:keyDownEvent(kKeyCodeEmpty, @"ư", @"ư")];
+ EXPECT_EQ([keyCallbacks count], 1u);
+ EXPECT_EQ([allCalls count], 1u);
+ EXPECT_EQ(allCalls[0], @(kEmbedderCall));
+ keyCallbacks[0](false);
+ EXPECT_EQ([keyCallbacks count], 1u);
+ EXPECT_EQ([allCalls count], 2u);
+ EXPECT_EQ(allCalls[1], @(kTextCall));
+ [keyCallbacks removeAllObjects];
+ [allCalls removeAllObjects];
+
+ [tester.manager handleEvent:keyUpEvent(kKeyCodeEmpty)];
+ EXPECT_EQ([keyCallbacks count], 1u);
+ keyCallbacks[0](false);
+ EXPECT_EQ([keyCallbacks count], 1u);
+ EXPECT_EQ([allCalls count], 2u);
+ [keyCallbacks removeAllObjects];
+ [allCalls removeAllObjects];
+
+ // Tap key O, which is converted to normal KeyO events, but the responses are
+ // slow.
+
+ [tester.manager handleEvent:keyDownEvent(kKeyCodeKeyO, @"o", @"o")];
+ [tester.manager handleEvent:keyUpEvent(kKeyCodeKeyO)];
+ EXPECT_EQ([keyCallbacks count], 1u);
+ EXPECT_EQ([allCalls count], 1u);
+ EXPECT_EQ(allCalls[0], @(kEmbedderCall));
+
+ // Tap key C, which results in two Backspace messages first - and here they
+ // arrive before the key O messages are responded.
+
+ [tester.manager handleEvent:keyDownEvent(kKeyCodeBackspace)];
+ [tester.manager handleEvent:keyUpEvent(kKeyCodeBackspace)];
+ EXPECT_EQ([keyCallbacks count], 1u);
+ EXPECT_EQ([allCalls count], 1u);
+
+ // The key O down is responded, which releases a text call (for KeyO down) and
+ // an embedder call (for KeyO up) immediately.
+ keyCallbacks[0](false);
+ EXPECT_EQ([keyCallbacks count], 2u);
+ EXPECT_EQ([allCalls count], 3u);
+ EXPECT_EQ(allCalls[1], @(kTextCall)); // The order is important!
+ EXPECT_EQ(allCalls[2], @(kEmbedderCall));
+
+ // The key O up is responded, which releases a text call (for KeyO up) and
+ // an embedder call (for Backspace down) immediately.
+ keyCallbacks[1](false);
+ EXPECT_EQ([keyCallbacks count], 3u);
+ EXPECT_EQ([allCalls count], 5u);
+ EXPECT_EQ(allCalls[3], @(kTextCall)); // The order is important!
+ EXPECT_EQ(allCalls[4], @(kEmbedderCall));
+
+ // Finish up callbacks.
+ keyCallbacks[2](false);
+ keyCallbacks[3](false);
+
+ return true;
+}
+
@end
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardViewDelegate.h b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardViewDelegate.h
index 970d75f..2a6e0bd 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterKeyboardViewDelegate.h
+++ b/shell/platform/darwin/macos/framework/Source/FlutterKeyboardViewDelegate.h
@@ -4,6 +4,7 @@
#import <Cocoa/Cocoa.h>
+#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterBinaryMessenger.h"
#import "flutter/shell/platform/embedder/embedder.h"
/**