[ios][secure_paste]show menu item based on info sent from framework (#161103)

This is moved from https://github.com/flutter/engine/pull/56362

*List which issues are fixed by this PR. You must list at least one
issue. An issue is not required if the PR fixes something trivial like a
typo.*

*If you had to change anything in the [flutter/tests] repo, include a
link to the migration guide as per the [breaking change policy].*

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
hellohuanlin 2025-02-11 13:54:38 -08:00 committed by GitHub
parent 6a412a5f71
commit b37e7aaaab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 404 additions and 3 deletions

View File

@ -1050,6 +1050,21 @@ static void SetEntryPoint(flutter::Settings* settings, NSString* entrypoint, NSS
arguments:@[ @(client) ]];
}
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView
shareSelectedText:(NSString*)selectedText {
[self.platformPlugin showShareViewController:selectedText];
}
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView
searchWebWithSelectedText:(NSString*)selectedText {
[self.platformPlugin searchWeb:selectedText];
}
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView
lookUpSelectedText:(NSString*)selectedText {
[self.platformPlugin showLookUpViewController:selectedText];
}
#pragma mark - FlutterViewEngineDelegate
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView showToolbar:(int)client {

View File

@ -14,6 +14,9 @@
- (instancetype)initWithEngine:(FlutterEngine*)engine NS_DESIGNATED_INITIALIZER;
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result;
- (void)showShareViewController:(NSString*)content;
- (void)searchWeb:(NSString*)searchTerm;
- (void)showLookUpViewController:(NSString*)term;
@end
namespace flutter {

View File

@ -67,6 +67,12 @@ typedef NS_ENUM(NSInteger, FlutterFloatingCursorDragState) {
didResignFirstResponderWithTextInputClient:(int)client;
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView
willDismissEditMenuWithTextInputClient:(int)client;
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView
shareSelectedText:(NSString*)selectedText;
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView
searchWebWithSelectedText:(NSString*)selectedText;
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView
lookUpSelectedText:(NSString*)selectedText;
@end
#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTDELEGATE_H_

View File

@ -802,6 +802,7 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point,
// etc)
@property(nonatomic, copy) NSString* temporarilyDeletedComposedCharacter;
@property(nonatomic, assign) CGRect editMenuTargetRect;
@property(nonatomic, strong) NSArray<NSDictionary*>* editMenuItems;
- (void)setEditableTransform:(NSArray*)matrix;
@end
@ -875,10 +876,123 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point,
return self;
}
- (void)handleSearchWebAction {
[self.textInputDelegate flutterTextInputView:self
searchWebWithSelectedText:[self textInRange:_selectedTextRange]];
}
- (void)handleLookUpAction {
[self.textInputDelegate flutterTextInputView:self
lookUpSelectedText:[self textInRange:_selectedTextRange]];
}
- (void)handleShareAction {
[self.textInputDelegate flutterTextInputView:self
shareSelectedText:[self textInRange:_selectedTextRange]];
}
// DFS algorithm to search a UICommand from the menu tree.
- (UICommand*)searchCommandWithSelector:(SEL)selector
element:(UIMenuElement*)element API_AVAILABLE(ios(16.0)) {
if ([element isKindOfClass:UICommand.class]) {
UICommand* command = (UICommand*)element;
return command.action == selector ? command : nil;
} else if ([element isKindOfClass:UIMenu.class]) {
NSArray<UIMenuElement*>* children = ((UIMenu*)element).children;
for (UIMenuElement* child in children) {
UICommand* result = [self searchCommandWithSelector:selector element:child];
if (result) {
return result;
}
}
return nil;
} else {
return nil;
}
}
- (void)addBasicEditingCommandToItems:(NSMutableArray*)items
type:(NSString*)type
selector:(SEL)selector
suggestedMenu:(UIMenu*)suggestedMenu {
UICommand* command = [self searchCommandWithSelector:selector element:suggestedMenu];
if (command) {
[items addObject:command];
} else {
FML_LOG(ERROR) << "Cannot find context menu item of type \"" << type.UTF8String << "\".";
}
}
- (void)addAdditionalBasicCommandToItems:(NSMutableArray*)items
type:(NSString*)type
selector:(SEL)selector
encodedItem:(NSDictionary<NSString*, id>*)encodedItem {
NSString* title = encodedItem[@"title"];
if (title) {
UICommand* command = [UICommand commandWithTitle:title
image:nil
action:selector
propertyList:nil];
[items addObject:command];
} else {
FML_LOG(ERROR) << "Missing title for context menu item of type \"" << type.UTF8String << "\".";
}
}
- (UIMenu*)editMenuInteraction:(UIEditMenuInteraction*)interaction
menuForConfiguration:(UIEditMenuConfiguration*)configuration
suggestedActions:(NSArray<UIMenuElement*>*)suggestedActions API_AVAILABLE(ios(16.0)) {
return [UIMenu menuWithChildren:suggestedActions];
UIMenu* suggestedMenu = [UIMenu menuWithChildren:suggestedActions];
if (!_editMenuItems) {
return suggestedMenu;
}
NSMutableArray* items = [NSMutableArray array];
for (NSDictionary<NSString*, id>* encodedItem in _editMenuItems) {
NSString* type = encodedItem[@"type"];
if ([type isEqualToString:@"copy"]) {
[self addBasicEditingCommandToItems:items
type:type
selector:@selector(copy:)
suggestedMenu:suggestedMenu];
} else if ([type isEqualToString:@"paste"]) {
[self addBasicEditingCommandToItems:items
type:type
selector:@selector(paste:)
suggestedMenu:suggestedMenu];
} else if ([type isEqualToString:@"cut"]) {
[self addBasicEditingCommandToItems:items
type:type
selector:@selector(cut:)
suggestedMenu:suggestedMenu];
} else if ([type isEqualToString:@"delete"]) {
[self addBasicEditingCommandToItems:items
type:type
selector:@selector(delete:)
suggestedMenu:suggestedMenu];
} else if ([type isEqualToString:@"selectAll"]) {
[self addBasicEditingCommandToItems:items
type:type
selector:@selector(selectAll:)
suggestedMenu:suggestedMenu];
} else if ([type isEqualToString:@"searchWeb"]) {
[self addAdditionalBasicCommandToItems:items
type:type
selector:@selector(handleSearchWebAction)
encodedItem:encodedItem];
} else if ([type isEqualToString:@"share"]) {
[self addAdditionalBasicCommandToItems:items
type:type
selector:@selector(handleShareAction)
encodedItem:encodedItem];
} else if ([type isEqualToString:@"lookUp"]) {
[self addAdditionalBasicCommandToItems:items
type:type
selector:@selector(handleLookUpAction)
encodedItem:encodedItem];
}
}
return [UIMenu menuWithChildren:items];
}
- (void)editMenuInteraction:(UIEditMenuInteraction*)interaction
@ -894,8 +1008,10 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point,
return _editMenuTargetRect;
}
- (void)showEditMenuWithTargetRect:(CGRect)targetRect API_AVAILABLE(ios(16.0)) {
- (void)showEditMenuWithTargetRect:(CGRect)targetRect
items:(NSArray<NSDictionary*>*)items API_AVAILABLE(ios(16.0)) {
_editMenuTargetRect = targetRect;
_editMenuItems = items;
UIEditMenuConfiguration* config =
[UIEditMenuConfiguration configurationWithIdentifier:nil sourcePoint:CGPointZero];
[self.editMenuInteraction presentEditMenuWithConfiguration:config];
@ -2574,7 +2690,7 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point,
[encodedTargetRect[@"x"] doubleValue], [encodedTargetRect[@"y"] doubleValue],
[encodedTargetRect[@"width"] doubleValue], [encodedTargetRect[@"height"] doubleValue]);
CGRect localTargetRect = [self.hostView convertRect:globalTargetRect toView:self.activeView];
[self.activeView showEditMenuWithTargetRect:localTargetRect];
[self.activeView showEditMenuWithTargetRect:localTargetRect items:args[@"items"]];
return YES;
}

View File

@ -30,6 +30,9 @@ FLUTTER_ASSERT_ARC
- (BOOL)isVisibleToAutofill;
- (id<FlutterTextInputDelegate>)textInputDelegate;
- (void)configureWithDictionary:(NSDictionary*)configuration;
- (void)handleSearchWebAction;
- (void)handleLookUpAction;
- (void)handleShareAction;
@end
@interface FlutterTextInputViewSpy : FlutterTextInputView
@ -3035,6 +3038,264 @@ FLUTTER_ASSERT_ARC
}
}
- (void)testEditMenu_shouldPresentEditMenuWithSuggestedItemsByDefaultIfNoFrameworkData {
if (@available(iOS 16.0, *)) {
FlutterTextInputPlugin* myInputPlugin =
[[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
FlutterViewController* myViewController = [[FlutterViewController alloc] init];
myInputPlugin.viewController = myViewController;
[myViewController loadView];
FlutterMethodCall* setClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
arguments:@[ @(123), self.mutableTemplateCopy ]];
[myInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
FlutterTextInputView* myInputView = myInputPlugin.activeView;
FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
OCMStub([mockInputView isFirstResponder]).andReturn(YES);
XCTestExpectation* expectation = [[XCTestExpectation alloc]
initWithDescription:@"presentEditMenuWithConfiguration must be called."];
id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
.andDo(^(NSInvocation* invocation) {
[expectation fulfill];
});
myInputView.frame = CGRectMake(10, 20, 30, 40);
NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
@{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
// No items provided from framework. Show the suggested items by default.
BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}];
XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
[self waitForExpectations:@[ expectation ] timeout:1.0];
UICommand* copyItem = [UICommand commandWithTitle:@"Copy"
image:nil
action:@selector(copy:)
propertyList:nil];
UICommand* pasteItem = [UICommand commandWithTitle:@"Paste"
image:nil
action:@selector(paste:)
propertyList:nil];
NSArray<UICommand*>* suggestedActions = @[ copyItem, pasteItem ];
UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
suggestedActions:suggestedActions];
XCTAssertEqualObjects(menu.children, suggestedActions,
@"Must show suggested items by default.");
}
}
- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsAndCorrectOrderingForBasicEditingActions {
if (@available(iOS 16.0, *)) {
FlutterTextInputPlugin* myInputPlugin =
[[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
FlutterViewController* myViewController = [[FlutterViewController alloc] init];
myInputPlugin.viewController = myViewController;
[myViewController loadView];
FlutterMethodCall* setClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
arguments:@[ @(123), self.mutableTemplateCopy ]];
[myInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
FlutterTextInputView* myInputView = myInputPlugin.activeView;
FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
OCMStub([mockInputView isFirstResponder]).andReturn(YES);
XCTestExpectation* expectation = [[XCTestExpectation alloc]
initWithDescription:@"presentEditMenuWithConfiguration must be called."];
id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
.andDo(^(NSInvocation* invocation) {
[expectation fulfill];
});
myInputView.frame = CGRectMake(10, 20, 30, 40);
NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
@{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
NSArray<NSDictionary<NSString*, id>*>* encodedItems =
@[ @{@"type" : @"paste"}, @{@"type" : @"copy"} ];
BOOL shownEditMenu =
[myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
[self waitForExpectations:@[ expectation ] timeout:1.0];
UICommand* copyItem = [UICommand commandWithTitle:@"Copy"
image:nil
action:@selector(copy:)
propertyList:nil];
UICommand* pasteItem = [UICommand commandWithTitle:@"Paste"
image:nil
action:@selector(paste:)
propertyList:nil];
NSArray<UICommand*>* suggestedActions = @[ copyItem, pasteItem ];
UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
suggestedActions:suggestedActions];
// The item ordering should follow the encoded data sent from the framework.
NSArray<UICommand*>* expectedChildren = @[ pasteItem, copyItem ];
XCTAssertEqualObjects(menu.children, expectedChildren);
}
}
- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsUnderNestedSubtreeForBasicEditingActions {
if (@available(iOS 16.0, *)) {
FlutterTextInputPlugin* myInputPlugin =
[[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
FlutterViewController* myViewController = [[FlutterViewController alloc] init];
myInputPlugin.viewController = myViewController;
[myViewController loadView];
FlutterMethodCall* setClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
arguments:@[ @(123), self.mutableTemplateCopy ]];
[myInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
FlutterTextInputView* myInputView = myInputPlugin.activeView;
FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
OCMStub([mockInputView isFirstResponder]).andReturn(YES);
XCTestExpectation* expectation = [[XCTestExpectation alloc]
initWithDescription:@"presentEditMenuWithConfiguration must be called."];
id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
.andDo(^(NSInvocation* invocation) {
[expectation fulfill];
});
myInputView.frame = CGRectMake(10, 20, 30, 40);
NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
@{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
NSArray<NSDictionary<NSString*, id>*>* encodedItems =
@[ @{@"type" : @"cut"}, @{@"type" : @"paste"}, @{@"type" : @"copy"} ];
BOOL shownEditMenu =
[myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
[self waitForExpectations:@[ expectation ] timeout:1.0];
UICommand* copyItem = [UICommand commandWithTitle:@"Copy"
image:nil
action:@selector(copy:)
propertyList:nil];
UICommand* cutItem = [UICommand commandWithTitle:@"Cut"
image:nil
action:@selector(cut:)
propertyList:nil];
UICommand* pasteItem = [UICommand commandWithTitle:@"Paste"
image:nil
action:@selector(paste:)
propertyList:nil];
/*
A more complex menu hierarchy for DFS:
menu
/ | \
copy menu menu
| \
paste menu
|
cut
*/
NSArray<UIMenuElement*>* suggestedActions = @[
copyItem, [UIMenu menuWithChildren:@[ pasteItem ]],
[UIMenu menuWithChildren:@[ [UIMenu menuWithChildren:@[ cutItem ]] ]]
];
UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
suggestedActions:suggestedActions];
// The item ordering should follow the encoded data sent from the framework.
NSArray<UICommand*>* expectedActions = @[ cutItem, pasteItem, copyItem ];
XCTAssertEqualObjects(menu.children, expectedActions);
}
}
- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForMoreAdditionalItems {
if (@available(iOS 16.0, *)) {
FlutterTextInputPlugin* myInputPlugin =
[[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
FlutterViewController* myViewController = [[FlutterViewController alloc] init];
myInputPlugin.viewController = myViewController;
[myViewController loadView];
FlutterMethodCall* setClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
arguments:@[ @(123), self.mutableTemplateCopy ]];
[myInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
FlutterTextInputView* myInputView = myInputPlugin.activeView;
FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
OCMStub([mockInputView isFirstResponder]).andReturn(YES);
XCTestExpectation* expectation = [[XCTestExpectation alloc]
initWithDescription:@"presentEditMenuWithConfiguration must be called."];
id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
.andDo(^(NSInvocation* invocation) {
[expectation fulfill];
});
myInputView.frame = CGRectMake(10, 20, 30, 40);
NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
@{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
NSArray<NSDictionary<NSString*, id>*>* encodedItems = @[
@{@"type" : @"searchWeb", @"title" : @"Search Web"},
@{@"type" : @"lookUp", @"title" : @"Look Up"}, @{@"type" : @"share", @"title" : @"Share"}
];
BOOL shownEditMenu =
[myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
[self waitForExpectations:@[ expectation ] timeout:1.0];
NSArray<UICommand*>* suggestedActions = @[
[UICommand commandWithTitle:@"copy" image:nil action:@selector(copy:) propertyList:nil],
];
UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
suggestedActions:suggestedActions];
XCTAssert(menu.children.count == 3, @"There must be 3 menu items");
XCTAssert(((UICommand*)menu.children[0]).action == @selector(handleSearchWebAction),
@"Must create search web item in the tree.");
XCTAssert(((UICommand*)menu.children[1]).action == @selector(handleLookUpAction),
@"Must create look up item in the tree.");
XCTAssert(((UICommand*)menu.children[2]).action == @selector(handleShareAction),
@"Must create share item in the tree.");
}
}
- (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
[UIApplication.sharedApplication.keyWindow addSubview:inputView];