Flutter Engine Uber Docs
Docs for the entire Flutter Engine repo.
 
Loading...
Searching...
No Matches
FlutterTextInputPluginTest.mm
Go to the documentation of this file.
1// Copyright 2013 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
8
9#import <OCMock/OCMock.h>
10#import <XCTest/XCTest.h>
11
16
18
19@interface FlutterEngine ()
21@end
22
23@interface FlutterTextInputView ()
24@property(nonatomic, copy) NSString* autofillId;
25- (void)setEditableTransform:(NSArray*)matrix;
26- (void)setTextInputClient:(int)client;
27- (void)setTextInputState:(NSDictionary*)state;
28- (void)setMarkedRect:(CGRect)markedRect;
29- (void)updateEditingState;
30- (BOOL)isVisibleToAutofill;
31- (id<FlutterTextInputDelegate>)textInputDelegate;
32- (void)configureWithDictionary:(NSDictionary*)configuration;
33- (void)handleSearchWebAction;
34- (void)handleLookUpAction;
35- (void)handleShareAction;
36@end
37
39@property(nonatomic, assign) UIAccessibilityNotifications receivedNotification;
40@property(nonatomic, assign) id receivedNotificationTarget;
41@property(nonatomic, assign) BOOL isAccessibilityFocused;
42
43- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target;
44
45@end
46
47@implementation FlutterTextInputViewSpy {
48}
49
50- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target {
51 self.receivedNotification = notification;
52 self.receivedNotificationTarget = target;
53}
54
55- (BOOL)accessibilityElementIsFocused {
56 return _isAccessibilityFocused;
57}
58
59@end
60
62@property(nonatomic, strong) UITextField* textField;
63@end
64
65@interface FlutterTextInputPlugin ()
66@property(nonatomic, assign) FlutterTextInputView* activeView;
67@property(nonatomic, readonly) UIView* inputHider;
68@property(nonatomic, readonly) UIView* keyboardViewContainer;
69@property(nonatomic, readonly) UIView* keyboardView;
70@property(nonatomic, assign) UIView* cachedFirstResponder;
71@property(nonatomic, readonly) CGRect keyboardRect;
72@property(nonatomic, readonly) BOOL pendingAutofillRemoval;
73@property(nonatomic, readonly) BOOL pendingInputViewRemoval;
74@property(nonatomic, readonly)
75 NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
76
77- (void)cleanUpViewHierarchy:(BOOL)includeActiveView
78 clearText:(BOOL)clearText
79 delayRemoval:(BOOL)delayRemoval;
80- (NSArray<UIView*>*)textInputViews;
81- (UIView*)hostView;
82- (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView;
83- (void)startLiveTextInput;
84- (void)showKeyboardAndRemoveScreenshot;
85
86@end
87
88namespace flutter {
89namespace {
90class MockPlatformViewDelegate : public PlatformView::Delegate {
91 public:
92 void OnPlatformViewCreated(std::unique_ptr<Surface> surface) override {}
93 void OnPlatformViewDestroyed() override {}
94 void OnPlatformViewScheduleFrame() override {}
95 void OnPlatformViewAddView(int64_t view_id,
96 const ViewportMetrics& viewport_metrics,
97 AddViewCallback callback) override {}
98 void OnPlatformViewRemoveView(int64_t view_id, RemoveViewCallback callback) override {}
99 void OnPlatformViewSendViewFocusEvent(const ViewFocusEvent& event) override {};
100 void OnPlatformViewSetNextFrameCallback(const fml::closure& closure) override {}
101 void OnPlatformViewSetViewportMetrics(int64_t view_id, const ViewportMetrics& metrics) override {}
102 const flutter::Settings& OnPlatformViewGetSettings() const override { return settings_; }
103 void OnPlatformViewDispatchPlatformMessage(std::unique_ptr<PlatformMessage> message) override {}
104 void OnPlatformViewDispatchPointerDataPacket(std::unique_ptr<PointerDataPacket> packet) override {
105 }
106 HitTestResponse OnPlatformViewHitTest(int64_t view_id, const flutter::PointData offset) override {
107 return {.has_platform_view = false};
108 }
109 void OnPlatformViewDispatchSemanticsAction(int64_t view_id,
110 int32_t node_id,
111 SemanticsAction action,
112 fml::MallocMapping args) override {}
113 void OnPlatformViewSetSemanticsEnabled(bool enabled) override {}
114 void OnPlatformViewSetAccessibilityFeatures(int32_t flags) override {}
115 void OnPlatformViewRegisterTexture(std::shared_ptr<Texture> texture) override {}
116 void OnPlatformViewUnregisterTexture(int64_t texture_id) override {}
117 void OnPlatformViewMarkTextureFrameAvailable(int64_t texture_id) override {}
118
119 void LoadDartDeferredLibrary(intptr_t loading_unit_id,
120 std::unique_ptr<const fml::Mapping> snapshot_data,
121 std::unique_ptr<const fml::Mapping> snapshot_instructions) override {
122 }
123 void LoadDartDeferredLibraryError(intptr_t loading_unit_id,
124 const std::string error_message,
125 bool transient) override {}
126 void UpdateAssetResolverByType(std::unique_ptr<flutter::AssetResolver> updated_asset_resolver,
128 std::shared_ptr<fml::BasicTaskRunner> OnPlatformViewGetShutdownSafeIOTaskRunner() const override {
129 return nullptr;
130 }
131
133};
134
135} // namespace
136} // namespace flutter
137
138@interface FlutterTextInputPluginTest : XCTestCase
139@end
140
141@implementation FlutterTextInputPluginTest {
142 NSDictionary* _template;
143 NSDictionary* _passwordTemplate;
146
148}
149
150- (void)setUp {
151 [super setUp];
152 engine = OCMClassMock([FlutterEngine class]);
153
154 textInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
155
156 viewController = [[FlutterViewController alloc] init];
158
159 // Clear pasteboard between tests.
160 UIPasteboard.generalPasteboard.items = @[];
161}
162
163- (void)tearDown {
164 textInputPlugin = nil;
165 engine = nil;
166 [textInputPlugin.autofillContext removeAllObjects];
167 [textInputPlugin cleanUpViewHierarchy:YES clearText:YES delayRemoval:NO];
168 [[[[textInputPlugin textInputView] superview] subviews]
169 makeObjectsPerformSelector:@selector(removeFromSuperview)];
170 viewController = nil;
171 [super tearDown];
172}
173
174- (void)setClientId:(int)clientId configuration:(NSDictionary*)config {
175 FlutterMethodCall* setClientCall =
176 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
177 arguments:@[ [NSNumber numberWithInt:clientId], config ]];
178 [textInputPlugin handleMethodCall:setClientCall
179 result:^(id _Nullable result){
180 }];
181}
182
183- (void)setClientClear {
184 FlutterMethodCall* clearClientCall =
185 [FlutterMethodCall methodCallWithMethodName:@"TextInput.clearClient" arguments:@[]];
186 [textInputPlugin handleMethodCall:clearClientCall
187 result:^(id _Nullable result){
188 }];
189}
190
191- (void)setTextInputShow {
192 FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
193 arguments:@[]];
194 [textInputPlugin handleMethodCall:setClientCall
195 result:^(id _Nullable result){
196 }];
197}
198
199- (void)setTextInputHide {
200 FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
201 arguments:@[]];
202 [textInputPlugin handleMethodCall:setClientCall
203 result:^(id _Nullable result){
204 }];
205}
206
207- (void)flushScheduledAsyncBlocks {
208 __block bool done = false;
209 XCTestExpectation* expectation =
210 [[XCTestExpectation alloc] initWithDescription:@"Testing on main queue"];
211 dispatch_async(dispatch_get_main_queue(), ^{
212 done = true;
213 });
214 dispatch_async(dispatch_get_main_queue(), ^{
215 XCTAssertTrue(done);
216 [expectation fulfill];
217 });
218 [self waitForExpectations:@[ expectation ] timeout:10];
219}
220
221- (NSMutableDictionary*)mutableTemplateCopy {
222 if (!_template) {
223 _template = @{
224 @"inputType" : @{@"name" : @"TextInuptType.text"},
225 @"keyboardAppearance" : @"Brightness.light",
226 @"obscureText" : @NO,
227 @"inputAction" : @"TextInputAction.unspecified",
228 @"smartDashesType" : @"0",
229 @"smartQuotesType" : @"0",
230 @"autocorrect" : @YES,
231 @"enableInteractiveSelection" : @YES,
232 };
233 }
234
235 return [_template mutableCopy];
236}
237
238- (NSArray<FlutterTextInputView*>*)installedInputViews {
239 return (NSArray<FlutterTextInputView*>*)[textInputPlugin.textInputViews
240 filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
241 [FlutterTextInputView class]]];
242}
243
244- (FlutterTextRange*)getLineRangeFromTokenizer:(id<UITextInputTokenizer>)tokenizer
245 atIndex:(NSInteger)index {
246 UITextRange* range =
247 [tokenizer rangeEnclosingPosition:[FlutterTextPosition positionWithIndex:index]
248 withGranularity:UITextGranularityLine
249 inDirection:UITextLayoutDirectionRight];
250 XCTAssertTrue([range isKindOfClass:[FlutterTextRange class]]);
251 return (FlutterTextRange*)range;
252}
253
254- (void)updateConfig:(NSDictionary*)config {
255 FlutterMethodCall* updateConfigCall =
256 [FlutterMethodCall methodCallWithMethodName:@"TextInput.updateConfig" arguments:config];
257 [textInputPlugin handleMethodCall:updateConfigCall
258 result:^(id _Nullable result){
259 }];
260}
261
262#pragma mark - Tests
263
264- (void)testWillNotCrashWhenViewControllerIsNil {
265 FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
266 FlutterTextInputPlugin* inputPlugin =
267 [[FlutterTextInputPlugin alloc] initWithDelegate:(id<FlutterTextInputDelegate>)flutterEngine];
268 XCTAssertNil(inputPlugin.viewController);
269 FlutterMethodCall* methodCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
270 arguments:nil];
271 XCTestExpectation* expectation = [[XCTestExpectation alloc] initWithDescription:@"result called"];
272
273 [inputPlugin handleMethodCall:methodCall
274 result:^(id _Nullable result) {
275 XCTAssertNil(result);
276 [expectation fulfill];
277 }];
278 XCTAssertNil(inputPlugin.activeView);
279 [self waitForExpectations:@[ expectation ] timeout:1.0];
280}
281
282- (void)testInvokeStartLiveTextInput {
283 FlutterMethodCall* methodCall =
284 [FlutterMethodCall methodCallWithMethodName:@"TextInput.startLiveTextInput" arguments:nil];
285 FlutterTextInputPlugin* mockPlugin = OCMPartialMock(textInputPlugin);
286 [mockPlugin handleMethodCall:methodCall
287 result:^(id _Nullable result){
288 }];
289 OCMVerify([mockPlugin startLiveTextInput]);
290}
291
292- (void)testNoDanglingEnginePointer {
293 __weak FlutterTextInputPlugin* weakFlutterTextInputPlugin;
294 FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
295 __weak FlutterEngine* weakFlutterEngine;
296
297 FlutterTextInputView* currentView;
298
299 // The engine instance will be deallocated after the autorelease pool is drained.
300 @autoreleasepool {
301 FlutterEngine* flutterEngine = OCMClassMock([FlutterEngine class]);
302 weakFlutterEngine = flutterEngine;
303 XCTAssertNotNil(weakFlutterEngine, @"flutter engine must not be nil");
304 FlutterTextInputPlugin* flutterTextInputPlugin = [[FlutterTextInputPlugin alloc]
305 initWithDelegate:(id<FlutterTextInputDelegate>)flutterEngine];
306 weakFlutterTextInputPlugin = flutterTextInputPlugin;
307 flutterTextInputPlugin.viewController = flutterViewController;
308
309 // Set client so the text input plugin has an active view.
310 NSDictionary* config = self.mutableTemplateCopy;
311 FlutterMethodCall* setClientCall =
312 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
313 arguments:@[ [NSNumber numberWithInt:123], config ]];
314 [flutterTextInputPlugin handleMethodCall:setClientCall
315 result:^(id _Nullable result){
316 }];
317 currentView = flutterTextInputPlugin.activeView;
318 }
319
320 XCTAssertNil(weakFlutterEngine, @"flutter engine must be nil");
321 XCTAssertNotNil(currentView, @"current view must not be nil");
322
323 XCTAssertNil(weakFlutterTextInputPlugin);
324 // Verify that the view can no longer access the deallocated engine/text input plugin
325 // instance.
326 XCTAssertNil(currentView.textInputDelegate);
327}
328
329- (void)testSecureInput {
330 NSDictionary* config = self.mutableTemplateCopy;
331 [config setValue:@"YES" forKey:@"obscureText"];
332 [self setClientId:123 configuration:config];
333
334 // Find all the FlutterTextInputViews we created.
335 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
336
337 // There are no autofill and the mock framework requested a secure entry. The first and only
338 // inserted FlutterTextInputView should be a secure text entry one.
339 FlutterTextInputView* inputView = inputFields[0];
340
341 // Verify secureTextEntry is set to the correct value.
342 XCTAssertTrue(inputView.secureTextEntry);
343
344 // Verify keyboardType is set to the default value.
345 XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeDefault);
346
347 // We should have only ever created one FlutterTextInputView.
348 XCTAssertEqual(inputFields.count, 1ul);
349
350 // The one FlutterTextInputView we inserted into the view hierarchy should be the text input
351 // plugin's active text input view.
352 XCTAssertEqual(inputView, textInputPlugin.textInputView);
353
354 // Despite not given an id in configuration, inputView has
355 // an autofill id.
356 XCTAssert(inputView.autofillId.length > 0);
357}
358
359- (void)testKeyboardType {
360 NSDictionary* config = self.mutableTemplateCopy;
361 [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
362 [self setClientId:123 configuration:config];
363
364 // Find all the FlutterTextInputViews we created.
365 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
366
367 FlutterTextInputView* inputView = inputFields[0];
368
369 // Verify keyboardType is set to the value specified in config.
370 XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeURL);
371}
372
373- (void)testKeyboardTypeWebSearch {
374 NSDictionary* config = self.mutableTemplateCopy;
375 [config setValue:@{@"name" : @"TextInputType.webSearch"} forKey:@"inputType"];
376 [self setClientId:123 configuration:config];
377
378 // Find all the FlutterTextInputViews we created.
379 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
380
381 FlutterTextInputView* inputView = inputFields[0];
382
383 // Verify keyboardType is set to the value specified in config.
384 XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeWebSearch);
385}
386
387- (void)testKeyboardTypeTwitter {
388 NSDictionary* config = self.mutableTemplateCopy;
389 [config setValue:@{@"name" : @"TextInputType.twitter"} forKey:@"inputType"];
390 [self setClientId:123 configuration:config];
391
392 // Find all the FlutterTextInputViews we created.
393 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
394
395 FlutterTextInputView* inputView = inputFields[0];
396
397 // Verify keyboardType is set to the value specified in config.
398 XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeTwitter);
399}
400
401- (void)testVisiblePasswordUseAlphanumeric {
402 NSDictionary* config = self.mutableTemplateCopy;
403 [config setValue:@{@"name" : @"TextInputType.visiblePassword"} forKey:@"inputType"];
404 [self setClientId:123 configuration:config];
405
406 // Find all the FlutterTextInputViews we created.
407 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
408
409 FlutterTextInputView* inputView = inputFields[0];
410
411 // Verify keyboardType is set to the value specified in config.
412 XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeASCIICapable);
413}
414
415- (void)testSettingKeyboardTypeNoneDisablesSystemKeyboard {
416 NSDictionary* config = self.mutableTemplateCopy;
417 [config setValue:@{@"name" : @"TextInputType.none"} forKey:@"inputType"];
418 [self setClientId:123 configuration:config];
419
420 // Verify the view's inputViewController is not nil;
421 XCTAssertNotNil(textInputPlugin.activeView.inputViewController);
422
423 [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
424 [self setClientId:124 configuration:config];
425 XCTAssertNotNil(textInputPlugin.activeView);
426 XCTAssertNil(textInputPlugin.activeView.inputViewController);
427}
428
429- (void)testAutocorrectionPromptRectAppearsBeforeIOS17AndDoesNotAppearAfterIOS17 {
430 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
431 [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
432
433 if (@available(iOS 17.0, *)) {
434 // Auto-correction prompt is disabled in iOS 17+.
435 OCMVerify(never(), [engine flutterTextInputView:inputView
436 showAutocorrectionPromptRectForStart:0
437 end:1
438 withClient:0]);
439 } else {
440 OCMVerify([engine flutterTextInputView:inputView
441 showAutocorrectionPromptRectForStart:0
442 end:1
443 withClient:0]);
444 }
445}
446
447- (void)testIgnoresSelectionChangeIfSelectionIsDisabled {
448 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
449 __block int updateCount = 0;
450 OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
451 .andDo(^(NSInvocation* invocation) {
452 updateCount++;
453 });
454
455 [inputView.text setString:@"Some initial text"];
456 XCTAssertEqual(updateCount, 0);
457
458 FlutterTextRange* textRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
459 [inputView setSelectedTextRange:textRange];
460 XCTAssertEqual(updateCount, 1);
461
462 // Disable the interactive selection.
463 NSDictionary* config = self.mutableTemplateCopy;
464 [config setValue:@(NO) forKey:@"enableInteractiveSelection"];
465 [config setValue:@(NO) forKey:@"obscureText"];
466 [config setValue:@(NO) forKey:@"enableDeltaModel"];
467 [inputView configureWithDictionary:config];
468
469 textRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(2, 3)];
470 [inputView setSelectedTextRange:textRange];
471 // The update count does not change.
472 XCTAssertEqual(updateCount, 1);
473}
474
475- (void)testAutocorrectionPromptRectDoesNotAppearDuringScribble {
476 // Auto-correction prompt is disabled in iOS 17+.
477 if (@available(iOS 17.0, *)) {
478 return;
479 }
480
481 if (@available(iOS 14.0, *)) {
482 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
483
484 __block int callCount = 0;
485 OCMStub([engine flutterTextInputView:inputView
486 showAutocorrectionPromptRectForStart:0
487 end:1
488 withClient:0])
489 .andDo(^(NSInvocation* invocation) {
490 callCount++;
491 });
492
493 [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
494 // showAutocorrectionPromptRectForStart fires in response to firstRectForRange
495 XCTAssertEqual(callCount, 1);
496
497 UIScribbleInteraction* scribbleInteraction =
498 [[UIScribbleInteraction alloc] initWithDelegate:inputView];
499
500 [inputView scribbleInteractionWillBeginWriting:scribbleInteraction];
501 [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
502 // showAutocorrectionPromptRectForStart does not fire in response to setMarkedText during a
503 // scribble interaction.firstRectForRange
504 XCTAssertEqual(callCount, 1);
505
506 [inputView scribbleInteractionDidFinishWriting:scribbleInteraction];
507 [inputView resetScribbleInteractionStatusIfEnding];
508 [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
509 // showAutocorrectionPromptRectForStart fires in response to firstRectForRange.
510 XCTAssertEqual(callCount, 2);
511
512 inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing;
513 [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
514 // showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange during a
515 // scribble-initiated focus.
516 XCTAssertEqual(callCount, 2);
517
518 inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused;
519 [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
520 // showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange after a
521 // scribble-initiated focus.
522 XCTAssertEqual(callCount, 2);
523
524 inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
525 [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
526 // showAutocorrectionPromptRectForStart fires in response to firstRectForRange.
527 XCTAssertEqual(callCount, 3);
528 }
529}
530
531- (void)testInputHiderOverlapWithTextWhenScribbleIsDisabledAfterIOS17AndDoesNotOverlapBeforeIOS17 {
532 FlutterTextInputPlugin* myInputPlugin =
533 [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
534
535 FlutterMethodCall* setClientCall =
536 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
537 arguments:@[ @(123), self.mutableTemplateCopy ]];
538 [myInputPlugin handleMethodCall:setClientCall
539 result:^(id _Nullable result){
540 }];
541
542 FlutterTextInputView* mockInputView = OCMPartialMock(myInputPlugin.activeView);
543 OCMStub([mockInputView isScribbleAvailable]).andReturn(NO);
544
545 // yOffset = 200.
546 NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];
547
548 FlutterMethodCall* setPlatformViewClientCall =
549 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditableSizeAndTransform"
550 arguments:@{@"transform" : yOffsetMatrix}];
551 [myInputPlugin handleMethodCall:setPlatformViewClientCall
552 result:^(id _Nullable result){
553 }];
554
555 if (@available(iOS 17, *)) {
556 XCTAssert(CGRectEqualToRect(myInputPlugin.inputHider.frame, CGRectMake(0, 200, 0, 0)),
557 @"The input hider should overlap with the text on and after iOS 17");
558
559 } else {
560 XCTAssert(CGRectEqualToRect(myInputPlugin.inputHider.frame, CGRectZero),
561 @"The input hider should be on the origin of screen on and before iOS 16.");
562 }
563}
564
565- (void)testTextRangeFromPositionMatchesUITextViewBehavior {
566 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
569
570 FlutterTextRange* flutterRange = (FlutterTextRange*)[inputView textRangeFromPosition:fromPosition
571 toPosition:toPosition];
572 NSRange range = flutterRange.range;
573
574 XCTAssertEqual(range.location, 0ul);
575 XCTAssertEqual(range.length, 2ul);
576}
577
578- (void)testTextInRange {
579 NSDictionary* config = self.mutableTemplateCopy;
580 [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
581 [self setClientId:123 configuration:config];
582 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
583 FlutterTextInputView* inputView = inputFields[0];
584
585 [inputView insertText:@"test"];
586
587 UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 20)];
588 NSString* substring = [inputView textInRange:range];
589 XCTAssertEqual(substring.length, 4ul);
590
591 range = [FlutterTextRange rangeWithNSRange:NSMakeRange(10, 20)];
592 substring = [inputView textInRange:range];
593 XCTAssertEqual(substring.length, 0ul);
594}
595
596- (void)testTextInRangeAcceptsNSNotFoundLocationGracefully {
597 NSDictionary* config = self.mutableTemplateCopy;
598 [self setClientId:123 configuration:config];
599 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
600 FlutterTextInputView* inputView = inputFields[0];
601
602 [inputView insertText:@"text"];
603 UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(NSNotFound, 0)];
604
605 NSString* substring = [inputView textInRange:range];
606 XCTAssertNil(substring);
607}
608
609- (void)testStandardEditActions {
610 NSDictionary* config = self.mutableTemplateCopy;
611 [self setClientId:123 configuration:config];
612 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
613 FlutterTextInputView* inputView = inputFields[0];
614
615 [inputView insertText:@"aaaa"];
616 [inputView selectAll:nil];
617 [inputView cut:nil];
618 [inputView insertText:@"bbbb"];
619 XCTAssertTrue([inputView canPerformAction:@selector(paste:) withSender:nil]);
620 [inputView paste:nil];
621 [inputView selectAll:nil];
622 [inputView copy:nil];
623 [inputView paste:nil];
624 [inputView selectAll:nil];
625 [inputView delete:nil];
626 [inputView paste:nil];
627 [inputView paste:nil];
628
629 UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 30)];
630 NSString* substring = [inputView textInRange:range];
631 XCTAssertEqualObjects(substring, @"bbbbaaaabbbbaaaa");
632}
633
634- (void)testCanPerformActionForSelectActions {
635 NSDictionary* config = self.mutableTemplateCopy;
636 [self setClientId:123 configuration:config];
637 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
638 FlutterTextInputView* inputView = inputFields[0];
639
640 XCTAssertFalse([inputView canPerformAction:@selector(selectAll:) withSender:nil]);
641
642 [inputView insertText:@"aaaa"];
643
644 XCTAssertTrue([inputView canPerformAction:@selector(selectAll:) withSender:nil]);
645}
646
647- (void)testCanPerformActionCaptureTextFromCamera {
648 if (@available(iOS 15.0, *)) {
649 NSDictionary* config = self.mutableTemplateCopy;
650 [self setClientId:123 configuration:config];
651 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
652 FlutterTextInputView* inputView = inputFields[0];
653
654 [inputView becomeFirstResponder];
655 XCTAssertTrue([inputView canPerformAction:@selector(captureTextFromCamera:) withSender:nil]);
656
657 [inputView insertText:@"test"];
658 [inputView selectAll:nil];
659 XCTAssertTrue([inputView canPerformAction:@selector(captureTextFromCamera:) withSender:nil]);
660 }
661}
662
663- (void)testDeletingBackward {
664 NSDictionary* config = self.mutableTemplateCopy;
665 [self setClientId:123 configuration:config];
666 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
667 FlutterTextInputView* inputView = inputFields[0];
668
669 [inputView insertText:@"ឹ😀 text 🥰👨‍👩‍👧‍👦🇺🇳ดี "];
670 [inputView deleteBackward];
671 [inputView deleteBackward];
672
673 // Thai vowel is removed.
674 XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰👨‍👩‍👧‍👦🇺🇳ด");
675 [inputView deleteBackward];
676 XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰👨‍👩‍👧‍👦🇺🇳");
677 [inputView deleteBackward];
678 XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰👨‍👩‍👧‍👦");
679 [inputView deleteBackward];
680 XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰");
681 [inputView deleteBackward];
682
683 XCTAssertEqualObjects(inputView.text, @"ឹ😀 text ");
684 [inputView deleteBackward];
685 [inputView deleteBackward];
686 [inputView deleteBackward];
687 [inputView deleteBackward];
688 [inputView deleteBackward];
689 [inputView deleteBackward];
690
691 XCTAssertEqualObjects(inputView.text, @"ឹ😀");
692 [inputView deleteBackward];
693 XCTAssertEqualObjects(inputView.text, @"ឹ");
694 [inputView deleteBackward];
695 XCTAssertEqualObjects(inputView.text, @"");
696}
697
698// This tests the workaround to fix an iOS 16 bug
699// See: https://github.com/flutter/flutter/issues/111494
700- (void)testSystemOnlyAddingPartialComposedCharacter {
701 NSDictionary* config = self.mutableTemplateCopy;
702 [self setClientId:123 configuration:config];
703 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
704 FlutterTextInputView* inputView = inputFields[0];
705
706 [inputView insertText:@"👨‍👩‍👧‍👦"];
707 [inputView deleteBackward];
708
709 // Insert the first unichar in the emoji.
710 [inputView insertText:[@"👨‍👩‍👧‍👦" substringWithRange:NSMakeRange(0, 1)]];
711 [inputView insertText:@"아"];
712
713 XCTAssertEqualObjects(inputView.text, @"👨‍👩‍👧‍👦아");
714
715 // Deleting 아.
716 [inputView deleteBackward];
717 // 👨‍👩‍👧‍👦 should be the current string.
718
719 [inputView insertText:@"😀"];
720 [inputView deleteBackward];
721 // Insert the first unichar in the emoji.
722 [inputView insertText:[@"😀" substringWithRange:NSMakeRange(0, 1)]];
723 [inputView insertText:@"아"];
724 XCTAssertEqualObjects(inputView.text, @"👨‍👩‍👧‍👦😀아");
725
726 // Deleting 아.
727 [inputView deleteBackward];
728 // 👨‍👩‍👧‍👦😀 should be the current string.
729
730 [inputView deleteBackward];
731 // Insert the first unichar in the emoji.
732 [inputView insertText:[@"😀" substringWithRange:NSMakeRange(0, 1)]];
733 [inputView insertText:@"아"];
734
735 XCTAssertEqualObjects(inputView.text, @"👨‍👩‍👧‍👦😀아");
736}
737
738- (void)testCachedComposedCharacterClearedAtKeyboardInteraction {
739 NSDictionary* config = self.mutableTemplateCopy;
740 [self setClientId:123 configuration:config];
741 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
742 FlutterTextInputView* inputView = inputFields[0];
743
744 [inputView insertText:@"👨‍👩‍👧‍👦"];
745 [inputView deleteBackward];
746 [inputView shouldChangeTextInRange:OCMClassMock([UITextRange class]) replacementText:@""];
747
748 // Insert the first unichar in the emoji.
749 NSString* brokenEmoji = [@"👨‍👩‍👧‍👦" substringWithRange:NSMakeRange(0, 1)];
750 [inputView insertText:brokenEmoji];
751 [inputView insertText:@"아"];
752
753 NSString* finalText = [NSString stringWithFormat:@"%@아", brokenEmoji];
754 XCTAssertEqualObjects(inputView.text, finalText);
755}
756
757- (void)testPastingNonTextDisallowed {
758 NSDictionary* config = self.mutableTemplateCopy;
759 [self setClientId:123 configuration:config];
760 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
761 FlutterTextInputView* inputView = inputFields[0];
762
763 UIPasteboard.generalPasteboard.color = UIColor.redColor;
764 XCTAssertNil(UIPasteboard.generalPasteboard.string);
765 XCTAssertFalse([inputView canPerformAction:@selector(paste:) withSender:nil]);
766 [inputView paste:nil];
767
768 XCTAssertEqualObjects(inputView.text, @"");
769}
770
771- (void)testNoZombies {
772 // Regression test for https://github.com/flutter/flutter/issues/62501.
773 FlutterSecureTextInputView* passwordView =
774 [[FlutterSecureTextInputView alloc] initWithOwner:textInputPlugin];
775
776 @autoreleasepool {
777 // Initialize the lazy textField.
778 [passwordView.textField description];
779 }
780 XCTAssert([[passwordView.textField description] containsString:@"TextField"]);
781}
782
783- (void)testInputViewCrash {
784 FlutterTextInputView* activeView = nil;
785 @autoreleasepool {
786 FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
787 FlutterTextInputPlugin* inputPlugin = [[FlutterTextInputPlugin alloc]
788 initWithDelegate:(id<FlutterTextInputDelegate>)flutterEngine];
789 activeView = inputPlugin.activeView;
790 }
791 [activeView updateEditingState];
792}
793
794- (void)testDoNotReuseInputViews {
795 NSDictionary* config = self.mutableTemplateCopy;
796 [self setClientId:123 configuration:config];
797 FlutterTextInputView* currentView = textInputPlugin.activeView;
798 [self setClientId:456 configuration:config];
799
800 XCTAssertNotNil(currentView);
801 XCTAssertNotNil(textInputPlugin.activeView);
802 XCTAssertNotEqual(currentView, textInputPlugin.activeView);
803}
804
805- (void)ensureOnlyActiveViewCanBecomeFirstResponder {
806 for (FlutterTextInputView* inputView in self.installedInputViews) {
807 XCTAssertEqual(inputView.canBecomeFirstResponder, inputView == textInputPlugin.activeView);
808 }
809}
810
811- (void)testPropagatePressEventsToViewController {
812 FlutterViewController* mockViewController = OCMPartialMock(viewController);
813 OCMStub([mockViewController pressesBegan:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]);
814 OCMStub([mockViewController pressesEnded:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]);
815
816 textInputPlugin.viewController = mockViewController;
817
818 NSDictionary* config = self.mutableTemplateCopy;
819 [self setClientId:123 configuration:config];
820 FlutterTextInputView* currentView = textInputPlugin.activeView;
821 [self setTextInputShow];
822
823 [currentView pressesBegan:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil]
824 withEvent:OCMClassMock([UIPressesEvent class])];
825
826 OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil]
827 withEvent:[OCMArg isNotNil]]);
828 OCMVerify(times(0), [mockViewController pressesEnded:[OCMArg isNotNil]
829 withEvent:[OCMArg isNotNil]]);
830
831 [currentView pressesEnded:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil]
832 withEvent:OCMClassMock([UIPressesEvent class])];
833
834 OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil]
835 withEvent:[OCMArg isNotNil]]);
836 OCMVerify(times(1), [mockViewController pressesEnded:[OCMArg isNotNil]
837 withEvent:[OCMArg isNotNil]]);
838}
839
840- (void)testPropagatePressEventsToViewController2 {
841 FlutterViewController* mockViewController = OCMPartialMock(viewController);
842 OCMStub([mockViewController pressesBegan:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]);
843 OCMStub([mockViewController pressesEnded:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]);
844
845 textInputPlugin.viewController = mockViewController;
846
847 NSDictionary* config = self.mutableTemplateCopy;
848 [self setClientId:123 configuration:config];
849 [self setTextInputShow];
850 FlutterTextInputView* currentView = textInputPlugin.activeView;
851
852 [currentView pressesBegan:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil]
853 withEvent:OCMClassMock([UIPressesEvent class])];
854
855 OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil]
856 withEvent:[OCMArg isNotNil]]);
857 OCMVerify(times(0), [mockViewController pressesEnded:[OCMArg isNotNil]
858 withEvent:[OCMArg isNotNil]]);
859
860 // Switch focus to a different view.
861 [self setClientId:321 configuration:config];
862 [self setTextInputShow];
863 NSAssert(textInputPlugin.activeView, @"active view must not be nil");
864 NSAssert(textInputPlugin.activeView != currentView, @"active view must change");
865 currentView = textInputPlugin.activeView;
866 [currentView pressesEnded:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil]
867 withEvent:OCMClassMock([UIPressesEvent class])];
868
869 OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil]
870 withEvent:[OCMArg isNotNil]]);
871 OCMVerify(times(1), [mockViewController pressesEnded:[OCMArg isNotNil]
872 withEvent:[OCMArg isNotNil]]);
873}
874
875- (void)testHotRestart {
876 flutter::MockPlatformViewDelegate mock_platform_view_delegate;
877 auto thread = std::make_unique<fml::Thread>("TextInputHotRestart");
878 auto thread_task_runner = thread->GetTaskRunner();
879 flutter::TaskRunners runners(/*label=*/self.name.UTF8String,
880 /*platform=*/thread_task_runner,
881 /*raster=*/thread_task_runner,
882 /*ui=*/thread_task_runner,
883 /*io=*/thread_task_runner);
884 id mockFlutterView = OCMClassMock([FlutterView class]);
885 id mockFlutterTextInputPlugin = OCMClassMock([FlutterTextInputPlugin class]);
886 id mockFlutterViewController = OCMClassMock([FlutterViewController class]);
887 OCMStub([mockFlutterViewController viewIfLoaded]).andReturn(mockFlutterView);
888 OCMStub([mockFlutterViewController textInputPlugin]).andReturn(mockFlutterTextInputPlugin);
889
891 thread_task_runner->PostTask([&] {
892 auto platform_view = std::make_unique<flutter::PlatformViewIOS>(
893 /*delegate=*/mock_platform_view_delegate,
894 /*rendering_api=*/mock_platform_view_delegate.settings_.enable_impeller
897 /*platform_views_controller=*/nil,
898 /*task_runners=*/runners,
899 /*is_gpu_disabled_sync_switch=*/std::make_shared<fml::SyncSwitch>());
900
901 platform_view->SetOwnerViewController(mockFlutterViewController);
902
903 OCMExpect([mockFlutterTextInputPlugin reset]);
904 platform_view->OnPreEngineRestart();
905 OCMVerifyAll(mockFlutterView);
906
907 latch.Signal();
908 });
909 latch.Wait();
910}
911
912- (void)testUpdateSecureTextEntry {
913 NSDictionary* config = self.mutableTemplateCopy;
914 [config setValue:@"YES" forKey:@"obscureText"];
915 [self setClientId:123 configuration:config];
916
917 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
918 FlutterTextInputView* inputView = OCMPartialMock(inputFields[0]);
919
920 __block int callCount = 0;
921 OCMStub([inputView reloadInputViews]).andDo(^(NSInvocation* invocation) {
922 callCount++;
923 });
924
925 XCTAssertTrue(inputView.isSecureTextEntry);
926
927 config = self.mutableTemplateCopy;
928 [config setValue:@"NO" forKey:@"obscureText"];
929 [self updateConfig:config];
930
931 XCTAssertEqual(callCount, 1);
932 XCTAssertFalse(inputView.isSecureTextEntry);
933}
934
935- (void)testInputActionContinueAction {
936 id mockBinaryMessenger = OCMClassMock([FlutterBinaryMessengerRelay class]);
937 FlutterEngine* testEngine = [[FlutterEngine alloc] init];
938 [testEngine setBinaryMessenger:mockBinaryMessenger];
939 [testEngine runWithEntrypoint:FlutterDefaultDartEntrypoint initialRoute:@"test"];
940
941 FlutterTextInputPlugin* inputPlugin =
942 [[FlutterTextInputPlugin alloc] initWithDelegate:(id<FlutterTextInputDelegate>)testEngine];
943 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:inputPlugin];
944
945 [testEngine flutterTextInputView:inputView
946 performAction:FlutterTextInputActionContinue
947 withClient:123];
948
949 FlutterMethodCall* methodCall =
950 [FlutterMethodCall methodCallWithMethodName:@"TextInputClient.performAction"
951 arguments:@[ @(123), @"TextInputAction.continueAction" ]];
952 NSData* encodedMethodCall = [[FlutterJSONMethodCodec sharedInstance] encodeMethodCall:methodCall];
953 OCMVerify([mockBinaryMessenger sendOnChannel:@"flutter/textinput" message:encodedMethodCall]);
954}
955
956- (void)testDisablingAutocorrectDisablesSpellChecking {
957 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
958
959 // Disable the interactive selection.
960 NSDictionary* config = self.mutableTemplateCopy;
961 [inputView configureWithDictionary:config];
962
963 XCTAssertEqual(inputView.autocorrectionType, UITextAutocorrectionTypeDefault);
964 XCTAssertEqual(inputView.spellCheckingType, UITextSpellCheckingTypeDefault);
965
966 [config setValue:@(NO) forKey:@"autocorrect"];
967 [inputView configureWithDictionary:config];
968
969 XCTAssertEqual(inputView.autocorrectionType, UITextAutocorrectionTypeNo);
970 XCTAssertEqual(inputView.spellCheckingType, UITextSpellCheckingTypeNo);
971}
972
973- (void)testEnableInlinePredictionFromConfiguration API_AVAILABLE(ios(17.0)) {
974 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
975 NSMutableDictionary* config = self.mutableTemplateCopy;
976
977 // Template does not include enableInlinePrediction -> disabled.
978 [inputView configureWithDictionary:config];
979 XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeNo);
980
981 [config setValue:@NO forKey:@"enableInlinePrediction"];
982 [inputView configureWithDictionary:config];
983 XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeNo);
984
985 [config setValue:@YES forKey:@"enableInlinePrediction"];
986 [inputView configureWithDictionary:config];
987 XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeYes);
988
989 // Explicit nil / missing key -> disabled.
990 [config removeObjectForKey:@"enableInlinePrediction"];
991 [inputView configureWithDictionary:config];
992 XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeNo);
993
994 // Key present with NSNull (e.g. framework sent null) -> disabled.
995 [config setValue:[NSNull null] forKey:@"enableInlinePrediction"];
996 [inputView configureWithDictionary:config];
997 XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeNo);
998}
999
1000- (void)testReplaceTestLocalAdjustSelectionAndMarkedTextRange {
1001 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1002 [inputView setMarkedText:@"test text" selectedRange:NSMakeRange(0, 5)];
1003 NSRange selectedTextRange = ((FlutterTextRange*)inputView.selectedTextRange).range;
1004 const NSRange markedTextRange = ((FlutterTextRange*)inputView.markedTextRange).range;
1005 XCTAssertEqual(selectedTextRange.location, 0ul);
1006 XCTAssertEqual(selectedTextRange.length, 5ul);
1007 XCTAssertEqual(markedTextRange.location, 0ul);
1008 XCTAssertEqual(markedTextRange.length, 9ul);
1009
1010 // Replaces space with space.
1011 [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(4, 1)] withText:@" "];
1012 selectedTextRange = ((FlutterTextRange*)inputView.selectedTextRange).range;
1013
1014 XCTAssertEqual(selectedTextRange.location, 5ul);
1015 XCTAssertEqual(selectedTextRange.length, 0ul);
1016 XCTAssertEqual(inputView.markedTextRange, nil);
1017}
1018
1019- (void)testFlutterTextInputViewIsNotClearWhenKeyboardShowAndHide {
1020 // Regression test for https://github.com/flutter/flutter/issues/172250.
1021 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1022 [inputView setMarkedText:@"test text" selectedRange:NSMakeRange(0, 5)];
1023 XCTAssertEqualObjects(inputView.text, @"test text");
1024
1025 // Showing keyboard does not trigger clearing of marked text.
1026 [self setTextInputShow];
1027 XCTAssertEqualObjects(inputView.text, @"test text");
1028
1029 // Hiding keyboard does not trigger clearing of marked text.
1030 [self setTextInputHide];
1031 XCTAssertEqualObjects(inputView.text, @"test text");
1032}
1033
1034- (void)testFlutterTextInputViewOnlyRespondsToInsertionPointColorBelowIOS17 {
1035 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1036 // [UITextInputTraits insertionPointColor] is non-public API, so @selector(insertionPointColor)
1037 // would generate a compile-time warning.
1038 SEL insertionPointColor = NSSelectorFromString(@"insertionPointColor");
1039 BOOL respondsToInsertionPointColor = [inputView respondsToSelector:insertionPointColor];
1040 if (@available(iOS 17, *)) {
1041 XCTAssertFalse(respondsToInsertionPointColor);
1042 } else {
1043 XCTAssertTrue(respondsToInsertionPointColor);
1044 }
1045}
1046
1047- (void)testSetAttributedMarkedTextSelectedRange API_AVAILABLE(ios(17.0)) {
1048 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1049 NSAttributedString* attributedText =
1050 [[NSAttributedString alloc] initWithString:@"inline prediction"
1051 attributes:@{
1052 NSForegroundColorAttributeName : [UIColor grayColor],
1053 }];
1054 [inputView setAttributedMarkedText:attributedText selectedRange:NSMakeRange(0, 7)];
1055
1056 XCTAssertEqualObjects(inputView.text, @"inline prediction");
1057 NSRange selectedRange = ((FlutterTextRange*)inputView.selectedTextRange).range;
1058 XCTAssertEqual(selectedRange.location, 0ul);
1059 XCTAssertEqual(selectedRange.length, 7ul);
1060 FlutterTextRange* markedRange = (FlutterTextRange*)inputView.markedTextRange;
1061 XCTAssertNotNil(markedRange);
1062 XCTAssertEqual(markedRange.range.location, 0ul);
1063 // Marked range length must match the attributed string length (17 for "inline prediction").
1064 XCTAssertEqual(markedRange.range.length, 17ul);
1065
1066 // Nil attributed string should behave like empty string.
1067 [inputView setAttributedMarkedText:nil selectedRange:NSMakeRange(0, 0)];
1068 XCTAssertEqualObjects(inputView.text, @"");
1069 XCTAssertNil(inputView.markedTextRange);
1070}
1071
1072#pragma mark - TextEditingDelta tests
1073- (void)testTextEditingDeltasAreGeneratedOnTextInput {
1074 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1075 inputView.enableDeltaModel = YES;
1076
1077 __block int updateCount = 0;
1078
1079 [inputView insertText:@"text to insert"];
1080 OCMExpect(
1081 [engine
1082 flutterTextInputView:inputView
1083 updateEditingClient:0
1084 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1085 return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1086 isEqualToString:@""]) &&
1087 ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1088 isEqualToString:@"text to insert"]) &&
1089 ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) &&
1090 ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 0);
1091 }]])
1092 .andDo(^(NSInvocation* invocation) {
1093 updateCount++;
1094 });
1095 XCTAssertEqual(updateCount, 0);
1096
1097 [self flushScheduledAsyncBlocks];
1098
1099 // Update the framework exactly once.
1100 XCTAssertEqual(updateCount, 1);
1101
1102 [inputView deleteBackward];
1103 OCMExpect([engine flutterTextInputView:inputView
1104 updateEditingClient:0
1105 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1106 return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1107 isEqualToString:@"text to insert"]) &&
1108 ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1109 isEqualToString:@""]) &&
1110 ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"]
1111 intValue] == 13) &&
1112 ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"]
1113 intValue] == 14);
1114 }]])
1115 .andDo(^(NSInvocation* invocation) {
1116 updateCount++;
1117 });
1118 [self flushScheduledAsyncBlocks];
1119 XCTAssertEqual(updateCount, 2);
1120
1121 inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1122 OCMExpect([engine flutterTextInputView:inputView
1123 updateEditingClient:0
1124 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1125 return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1126 isEqualToString:@"text to inser"]) &&
1127 ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1128 isEqualToString:@""]) &&
1129 ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"]
1130 intValue] == -1) &&
1131 ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"]
1132 intValue] == -1);
1133 }]])
1134 .andDo(^(NSInvocation* invocation) {
1135 updateCount++;
1136 });
1137 [self flushScheduledAsyncBlocks];
1138 XCTAssertEqual(updateCount, 3);
1139
1140 [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
1141 withText:@"replace text"];
1142 OCMExpect(
1143 [engine
1144 flutterTextInputView:inputView
1145 updateEditingClient:0
1146 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1147 return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1148 isEqualToString:@"text to inser"]) &&
1149 ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1150 isEqualToString:@"replace text"]) &&
1151 ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) &&
1152 ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 1);
1153 }]])
1154 .andDo(^(NSInvocation* invocation) {
1155 updateCount++;
1156 });
1157 [self flushScheduledAsyncBlocks];
1158 XCTAssertEqual(updateCount, 4);
1159
1160 [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1161 OCMExpect([engine flutterTextInputView:inputView
1162 updateEditingClient:0
1163 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1164 return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1165 isEqualToString:@"replace textext to inser"]) &&
1166 ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1167 isEqualToString:@"marked text"]) &&
1168 ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"]
1169 intValue] == 12) &&
1170 ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"]
1171 intValue] == 12);
1172 }]])
1173 .andDo(^(NSInvocation* invocation) {
1174 updateCount++;
1175 });
1176 [self flushScheduledAsyncBlocks];
1177 XCTAssertEqual(updateCount, 5);
1178
1179 [inputView unmarkText];
1180 OCMExpect([engine
1181 flutterTextInputView:inputView
1182 updateEditingClient:0
1183 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1184 return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1185 isEqualToString:@"replace textmarked textext to inser"]) &&
1186 ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1187 isEqualToString:@""]) &&
1188 ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] ==
1189 -1) &&
1190 ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] ==
1191 -1);
1192 }]])
1193 .andDo(^(NSInvocation* invocation) {
1194 updateCount++;
1195 });
1196 [self flushScheduledAsyncBlocks];
1197
1198 XCTAssertEqual(updateCount, 6);
1199 OCMVerifyAll(engine);
1200}
1201
1202- (void)testTextEditingDeltasAreBatchedAndForwardedToFramework {
1203 // Setup
1204 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1205 inputView.enableDeltaModel = YES;
1206
1207 // Expected call.
1208 OCMExpect([engine flutterTextInputView:inputView
1209 updateEditingClient:0
1210 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1211 NSArray* deltas = state[@"deltas"];
1212 NSDictionary* firstDelta = deltas[0];
1213 NSDictionary* secondDelta = deltas[1];
1214 NSDictionary* thirdDelta = deltas[2];
1215 return [firstDelta[@"oldText"] isEqualToString:@""] &&
1216 [firstDelta[@"deltaText"] isEqualToString:@"-"] &&
1217 [firstDelta[@"deltaStart"] intValue] == 0 &&
1218 [firstDelta[@"deltaEnd"] intValue] == 0 &&
1219 [secondDelta[@"oldText"] isEqualToString:@"-"] &&
1220 [secondDelta[@"deltaText"] isEqualToString:@""] &&
1221 [secondDelta[@"deltaStart"] intValue] == 0 &&
1222 [secondDelta[@"deltaEnd"] intValue] == 1 &&
1223 [thirdDelta[@"oldText"] isEqualToString:@""] &&
1224 [thirdDelta[@"deltaText"] isEqualToString:@"—"] &&
1225 [thirdDelta[@"deltaStart"] intValue] == 0 &&
1226 [thirdDelta[@"deltaEnd"] intValue] == 0;
1227 }]]);
1228
1229 // Simulate user input.
1230 [inputView insertText:@"-"];
1231 [inputView deleteBackward];
1232 [inputView insertText:@"—"];
1233
1234 [self flushScheduledAsyncBlocks];
1235 OCMVerifyAll(engine);
1236}
1237
1238- (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextReplacement {
1239 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1240 inputView.enableDeltaModel = YES;
1241
1242 __block int updateCount = 0;
1243 OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1244 .andDo(^(NSInvocation* invocation) {
1245 updateCount++;
1246 });
1247
1248 [inputView.text setString:@"Some initial text"];
1249 XCTAssertEqual(updateCount, 0);
1250
1251 UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
1252 inputView.markedTextRange = range;
1253 inputView.selectedTextRange = nil;
1254 [self flushScheduledAsyncBlocks];
1255 XCTAssertEqual(updateCount, 1);
1256
1257 [inputView setMarkedText:@"new marked text." selectedRange:NSMakeRange(0, 1)];
1258 OCMVerify([engine
1259 flutterTextInputView:inputView
1260 updateEditingClient:0
1261 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1262 return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1263 isEqualToString:@"Some initial text"]) &&
1264 ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1265 isEqualToString:@"new marked text."]) &&
1266 ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
1267 ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
1268 }]]);
1269 [self flushScheduledAsyncBlocks];
1270 XCTAssertEqual(updateCount, 2);
1271}
1272
1273- (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextInsertion {
1274 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1275 inputView.enableDeltaModel = YES;
1276
1277 __block int updateCount = 0;
1278 OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1279 .andDo(^(NSInvocation* invocation) {
1280 updateCount++;
1281 });
1282
1283 [inputView.text setString:@"Some initial text"];
1284 [self flushScheduledAsyncBlocks];
1285 XCTAssertEqual(updateCount, 0);
1286
1287 UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
1288 inputView.markedTextRange = range;
1289 inputView.selectedTextRange = nil;
1290 [self flushScheduledAsyncBlocks];
1291 XCTAssertEqual(updateCount, 1);
1292
1293 [inputView setMarkedText:@"text." selectedRange:NSMakeRange(0, 1)];
1294 OCMVerify([engine
1295 flutterTextInputView:inputView
1296 updateEditingClient:0
1297 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1298 return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1299 isEqualToString:@"Some initial text"]) &&
1300 ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1301 isEqualToString:@"text."]) &&
1302 ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
1303 ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
1304 }]]);
1305 [self flushScheduledAsyncBlocks];
1306 XCTAssertEqual(updateCount, 2);
1307}
1308
1309- (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextDeletion {
1310 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1311 inputView.enableDeltaModel = YES;
1312
1313 __block int updateCount = 0;
1314 OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1315 .andDo(^(NSInvocation* invocation) {
1316 updateCount++;
1317 });
1318
1319 [inputView.text setString:@"Some initial text"];
1320 [self flushScheduledAsyncBlocks];
1321 XCTAssertEqual(updateCount, 0);
1322
1323 UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
1324 inputView.markedTextRange = range;
1325 inputView.selectedTextRange = nil;
1326 [self flushScheduledAsyncBlocks];
1327 XCTAssertEqual(updateCount, 1);
1328
1329 [inputView setMarkedText:@"tex" selectedRange:NSMakeRange(0, 1)];
1330 OCMVerify([engine
1331 flutterTextInputView:inputView
1332 updateEditingClient:0
1333 withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1334 return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1335 isEqualToString:@"Some initial text"]) &&
1336 ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1337 isEqualToString:@"tex"]) &&
1338 ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
1339 ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
1340 }]]);
1341 [self flushScheduledAsyncBlocks];
1342 XCTAssertEqual(updateCount, 2);
1343}
1344
1345#pragma mark - EditingState tests
1346
1347- (void)testUITextInputCallsUpdateEditingStateOnce {
1348 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1349
1350 __block int updateCount = 0;
1351 OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
1352 .andDo(^(NSInvocation* invocation) {
1353 updateCount++;
1354 });
1355
1356 [inputView insertText:@"text to insert"];
1357 // Update the framework exactly once.
1358 XCTAssertEqual(updateCount, 1);
1359
1360 [inputView deleteBackward];
1361 XCTAssertEqual(updateCount, 2);
1362
1363 inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1364 XCTAssertEqual(updateCount, 3);
1365
1366 [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
1367 withText:@"replace text"];
1368 XCTAssertEqual(updateCount, 4);
1369
1370 [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1371 XCTAssertEqual(updateCount, 5);
1372
1373 [inputView unmarkText];
1374 XCTAssertEqual(updateCount, 6);
1375}
1376
1377- (void)testUITextInputCallsUpdateEditingStateWithDeltaOnce {
1378 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1379 inputView.enableDeltaModel = YES;
1380
1381 __block int updateCount = 0;
1382 OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1383 .andDo(^(NSInvocation* invocation) {
1384 updateCount++;
1385 });
1386
1387 [inputView insertText:@"text to insert"];
1388 [self flushScheduledAsyncBlocks];
1389 // Update the framework exactly once.
1390 XCTAssertEqual(updateCount, 1);
1391
1392 [inputView deleteBackward];
1393 [self flushScheduledAsyncBlocks];
1394 XCTAssertEqual(updateCount, 2);
1395
1396 inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1397 [self flushScheduledAsyncBlocks];
1398 XCTAssertEqual(updateCount, 3);
1399
1400 [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
1401 withText:@"replace text"];
1402 [self flushScheduledAsyncBlocks];
1403 XCTAssertEqual(updateCount, 4);
1404
1405 [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1406 [self flushScheduledAsyncBlocks];
1407 XCTAssertEqual(updateCount, 5);
1408
1409 [inputView unmarkText];
1410 [self flushScheduledAsyncBlocks];
1411 XCTAssertEqual(updateCount, 6);
1412}
1413
1414- (void)testTextChangesDoNotTriggerUpdateEditingClient {
1415 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1416
1417 __block int updateCount = 0;
1418 OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
1419 .andDo(^(NSInvocation* invocation) {
1420 updateCount++;
1421 });
1422
1423 [inputView.text setString:@"BEFORE"];
1424 XCTAssertEqual(updateCount, 0);
1425
1426 inputView.markedTextRange = nil;
1427 inputView.selectedTextRange = nil;
1428 XCTAssertEqual(updateCount, 1);
1429
1430 // Text changes don't trigger an update.
1431 XCTAssertEqual(updateCount, 1);
1432 [inputView setTextInputState:@{@"text" : @"AFTER"}];
1433 XCTAssertEqual(updateCount, 1);
1434 [inputView setTextInputState:@{@"text" : @"AFTER"}];
1435 XCTAssertEqual(updateCount, 1);
1436
1437 // Selection changes don't trigger an update.
1438 [inputView
1439 setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}];
1440 XCTAssertEqual(updateCount, 1);
1441 [inputView
1442 setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}];
1443 XCTAssertEqual(updateCount, 1);
1444
1445 // Composing region changes don't trigger an update.
1446 [inputView
1447 setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}];
1448 XCTAssertEqual(updateCount, 1);
1449 [inputView
1450 setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1451 XCTAssertEqual(updateCount, 1);
1452}
1453
1454- (void)testTextChangesDoNotTriggerUpdateEditingClientWithDelta {
1455 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1456 inputView.enableDeltaModel = YES;
1457
1458 __block int updateCount = 0;
1459 OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1460 .andDo(^(NSInvocation* invocation) {
1461 updateCount++;
1462 });
1463
1464 [inputView.text setString:@"BEFORE"];
1465 [self flushScheduledAsyncBlocks];
1466 XCTAssertEqual(updateCount, 0);
1467
1468 inputView.markedTextRange = nil;
1469 inputView.selectedTextRange = nil;
1470 [self flushScheduledAsyncBlocks];
1471 XCTAssertEqual(updateCount, 1);
1472
1473 // Text changes don't trigger an update.
1474 XCTAssertEqual(updateCount, 1);
1475 [inputView setTextInputState:@{@"text" : @"AFTER"}];
1476 [self flushScheduledAsyncBlocks];
1477 XCTAssertEqual(updateCount, 1);
1478
1479 [inputView setTextInputState:@{@"text" : @"AFTER"}];
1480 [self flushScheduledAsyncBlocks];
1481 XCTAssertEqual(updateCount, 1);
1482
1483 // Selection changes don't trigger an update.
1484 [inputView
1485 setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}];
1486 [self flushScheduledAsyncBlocks];
1487 XCTAssertEqual(updateCount, 1);
1488
1489 [inputView
1490 setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}];
1491 [self flushScheduledAsyncBlocks];
1492 XCTAssertEqual(updateCount, 1);
1493
1494 // Composing region changes don't trigger an update.
1495 [inputView
1496 setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}];
1497 [self flushScheduledAsyncBlocks];
1498 XCTAssertEqual(updateCount, 1);
1499
1500 [inputView
1501 setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1502 [self flushScheduledAsyncBlocks];
1503 XCTAssertEqual(updateCount, 1);
1504}
1505
1506- (void)testUITextInputAvoidUnnecessaryUndateEditingClientCalls {
1507 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1508
1509 __block int updateCount = 0;
1510 OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
1511 .andDo(^(NSInvocation* invocation) {
1512 updateCount++;
1513 });
1514
1515 [inputView unmarkText];
1516 // updateEditingClient shouldn't fire as the text is already unmarked.
1517 XCTAssertEqual(updateCount, 0);
1518
1519 [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1520 // updateEditingClient fires in response to setMarkedText.
1521 XCTAssertEqual(updateCount, 1);
1522
1523 [inputView unmarkText];
1524 // updateEditingClient fires in response to unmarkText.
1525 XCTAssertEqual(updateCount, 2);
1526}
1527
1528- (void)testCanCopyPasteWithScribbleEnabled {
1529 if (@available(iOS 14.0, *)) {
1530 NSDictionary* config = self.mutableTemplateCopy;
1531 [self setClientId:123 configuration:config];
1532 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
1533 FlutterTextInputView* inputView = inputFields[0];
1534
1535 FlutterTextInputView* mockInputView = OCMPartialMock(inputView);
1536 OCMStub([mockInputView isScribbleAvailable]).andReturn(YES);
1537
1538 [mockInputView insertText:@"aaaa"];
1539 [mockInputView selectAll:nil];
1540
1541 XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:NULL]);
1542 XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:@"sender"]);
1543 XCTAssertFalse([mockInputView canPerformAction:@selector(paste:) withSender:NULL]);
1544 XCTAssertFalse([mockInputView canPerformAction:@selector(paste:) withSender:@"sender"]);
1545
1546 [mockInputView copy:NULL];
1547 XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:NULL]);
1548 XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:@"sender"]);
1549 XCTAssertTrue([mockInputView canPerformAction:@selector(paste:) withSender:NULL]);
1550 XCTAssertTrue([mockInputView canPerformAction:@selector(paste:) withSender:@"sender"]);
1551 }
1552}
1553
1554- (void)testSetMarkedTextDuringScribbleDoesNotTriggerUpdateEditingClient {
1555 if (@available(iOS 14.0, *)) {
1556 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1557
1558 __block int updateCount = 0;
1559 OCMStub([engine flutterTextInputView:inputView
1560 updateEditingClient:0
1561 withState:[OCMArg isNotNil]])
1562 .andDo(^(NSInvocation* invocation) {
1563 updateCount++;
1564 });
1565
1566 [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1567 // updateEditingClient fires in response to setMarkedText.
1568 XCTAssertEqual(updateCount, 1);
1569
1570 UIScribbleInteraction* scribbleInteraction =
1571 [[UIScribbleInteraction alloc] initWithDelegate:inputView];
1572
1573 [inputView scribbleInteractionWillBeginWriting:scribbleInteraction];
1574 [inputView setMarkedText:@"during writing" selectedRange:NSMakeRange(1, 2)];
1575 // updateEditingClient does not fire in response to setMarkedText during a scribble interaction.
1576 XCTAssertEqual(updateCount, 1);
1577
1578 [inputView scribbleInteractionDidFinishWriting:scribbleInteraction];
1579 [inputView resetScribbleInteractionStatusIfEnding];
1580 [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1581 // updateEditingClient fires in response to setMarkedText.
1582 XCTAssertEqual(updateCount, 2);
1583
1584 inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing;
1585 [inputView setMarkedText:@"during focus" selectedRange:NSMakeRange(1, 2)];
1586 // updateEditingClient does not fire in response to setMarkedText during a scribble-initiated
1587 // focus.
1588 XCTAssertEqual(updateCount, 2);
1589
1590 inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused;
1591 [inputView setMarkedText:@"after focus" selectedRange:NSMakeRange(2, 3)];
1592 // updateEditingClient does not fire in response to setMarkedText after a scribble-initiated
1593 // focus.
1594 XCTAssertEqual(updateCount, 2);
1595
1596 inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
1597 [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1598 // updateEditingClient fires in response to setMarkedText.
1599 XCTAssertEqual(updateCount, 3);
1600 }
1601}
1602
1603- (void)testUpdateEditingClientNegativeSelection {
1604 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1605
1606 [inputView.text setString:@"SELECTION"];
1607 inputView.markedTextRange = nil;
1608 inputView.selectedTextRange = nil;
1609
1610 [inputView setTextInputState:@{
1611 @"text" : @"SELECTION",
1612 @"selectionBase" : @-1,
1613 @"selectionExtent" : @-1
1614 }];
1615 [inputView updateEditingState];
1616 OCMVerify([engine flutterTextInputView:inputView
1617 updateEditingClient:0
1618 withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1619 return ([state[@"selectionBase"] intValue]) == 0 &&
1620 ([state[@"selectionExtent"] intValue] == 0);
1621 }]]);
1622
1623 // Returns (0, 0) when either end goes below 0.
1624 [inputView
1625 setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @-1, @"selectionExtent" : @1}];
1626 [inputView updateEditingState];
1627 OCMVerify([engine flutterTextInputView:inputView
1628 updateEditingClient:0
1629 withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1630 return ([state[@"selectionBase"] intValue]) == 0 &&
1631 ([state[@"selectionExtent"] intValue] == 0);
1632 }]]);
1633
1634 [inputView
1635 setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @-1}];
1636 [inputView updateEditingState];
1637 OCMVerify([engine flutterTextInputView:inputView
1638 updateEditingClient:0
1639 withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1640 return ([state[@"selectionBase"] intValue]) == 0 &&
1641 ([state[@"selectionExtent"] intValue] == 0);
1642 }]]);
1643}
1644
1645- (void)testUpdateEditingClientSelectionClamping {
1646 // Regression test for https://github.com/flutter/flutter/issues/62992.
1647 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1648
1649 [inputView.text setString:@"SELECTION"];
1650 inputView.markedTextRange = nil;
1651 inputView.selectedTextRange = nil;
1652
1653 [inputView
1654 setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @0}];
1655 [inputView updateEditingState];
1656 OCMVerify([engine flutterTextInputView:inputView
1657 updateEditingClient:0
1658 withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1659 return ([state[@"selectionBase"] intValue]) == 0 &&
1660 ([state[@"selectionExtent"] intValue] == 0);
1661 }]]);
1662
1663 // Needs clamping.
1664 [inputView setTextInputState:@{
1665 @"text" : @"SELECTION",
1666 @"selectionBase" : @0,
1667 @"selectionExtent" : @9999
1668 }];
1669 [inputView updateEditingState];
1670
1671 OCMVerify([engine flutterTextInputView:inputView
1672 updateEditingClient:0
1673 withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1674 return ([state[@"selectionBase"] intValue]) == 0 &&
1675 ([state[@"selectionExtent"] intValue] == 9);
1676 }]]);
1677
1678 // No clamping needed, but in reverse direction.
1679 [inputView
1680 setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @0}];
1681 [inputView updateEditingState];
1682 OCMVerify([engine flutterTextInputView:inputView
1683 updateEditingClient:0
1684 withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1685 return ([state[@"selectionBase"] intValue]) == 0 &&
1686 ([state[@"selectionExtent"] intValue] == 1);
1687 }]]);
1688
1689 // Both ends need clamping.
1690 [inputView setTextInputState:@{
1691 @"text" : @"SELECTION",
1692 @"selectionBase" : @9999,
1693 @"selectionExtent" : @9999
1694 }];
1695 [inputView updateEditingState];
1696 OCMVerify([engine flutterTextInputView:inputView
1697 updateEditingClient:0
1698 withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1699 return ([state[@"selectionBase"] intValue]) == 9 &&
1700 ([state[@"selectionExtent"] intValue] == 9);
1701 }]]);
1702}
1703
1704- (void)testInputViewsHasNonNilInputDelegate {
1705 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1706 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
1707
1708 [inputView setTextInputClient:123];
1709 [inputView reloadInputViews];
1710 [inputView becomeFirstResponder];
1711 NSAssert(inputView.isFirstResponder, @"inputView is not first responder");
1712 inputView.inputDelegate = nil;
1713
1714 FlutterTextInputView* mockInputView = OCMPartialMock(inputView);
1715 [mockInputView
1716 setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1717 OCMVerify([mockInputView setInputDelegate:[OCMArg isNotNil]]);
1718 [inputView removeFromSuperview];
1719}
1720
1721- (void)testInputViewsDoNotHaveUITextInteractions {
1722 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1723 BOOL hasTextInteraction = NO;
1724 for (id interaction in inputView.interactions) {
1725 hasTextInteraction = [interaction isKindOfClass:[UITextInteraction class]];
1726 if (hasTextInteraction) {
1727 break;
1728 }
1729 }
1730 XCTAssertFalse(hasTextInteraction);
1731}
1732
1733#pragma mark - UITextInput methods - Tests
1734
1735- (void)testUpdateFirstRectForRange {
1736 [self setClientId:123 configuration:self.mutableTemplateCopy];
1737
1738 FlutterTextInputView* inputView = textInputPlugin.activeView;
1739 textInputPlugin.viewController.view.frame = CGRectMake(0, 0, 0, 0);
1740
1741 [inputView
1742 setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1743
1744 CGRect kInvalidFirstRect = CGRectMake(-1, -1, 9999, 9999);
1745 FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1746 // yOffset = 200.
1747 NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];
1748 NSArray* zeroMatrix = @[ @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0 ];
1749 // This matrix can be generated by running this dart code snippet:
1750 // Matrix4.identity()..scale(3.0)..rotateZ(math.pi/2)..translate(1.0, 2.0,
1751 // 3.0);
1752 NSArray* affineMatrix = @[
1753 @(0.0), @(3.0), @(0.0), @(0.0), @(-3.0), @(0.0), @(0.0), @(0.0), @(0.0), @(0.0), @(3.0), @(0.0),
1754 @(-6.0), @(3.0), @(9.0), @(1.0)
1755 ];
1756
1757 // Invalid since we don't have the transform or the rect.
1758 XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1759
1760 [inputView setEditableTransform:yOffsetMatrix];
1761 // Invalid since we don't have the rect.
1762 XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1763
1764 // Valid rect and transform.
1765 CGRect testRect = CGRectMake(0, 0, 100, 100);
1766 [inputView setMarkedRect:testRect];
1767
1768 CGRect finalRect = CGRectOffset(testRect, 0, 200);
1769 XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
1770 // Idempotent.
1771 XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
1772
1773 // Use an invalid matrix:
1774 [inputView setEditableTransform:zeroMatrix];
1775 // Invalid matrix is invalid.
1776 XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1777 XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1778
1779 // Revert the invalid matrix change.
1780 [inputView setEditableTransform:yOffsetMatrix];
1781 [inputView setMarkedRect:testRect];
1782 XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
1783
1784 // Use an invalid rect:
1785 [inputView setMarkedRect:kInvalidFirstRect];
1786 // Invalid marked rect is invalid.
1787 XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1788 XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1789
1790 // Use a 3d affine transform that does 3d-scaling, z-index rotating and 3d translation.
1791 [inputView setEditableTransform:affineMatrix];
1792 [inputView setMarkedRect:testRect];
1793 XCTAssertTrue(
1794 CGRectEqualToRect(CGRectMake(-306, 3, 300, 300), [inputView firstRectForRange:range]));
1795
1796 NSAssert(inputView.superview, @"inputView is not in the view hierarchy!");
1797 const CGPoint offset = CGPointMake(113, 119);
1798 CGRect currentFrame = inputView.frame;
1799 currentFrame.origin = offset;
1800 inputView.frame = currentFrame;
1801 // Moving the input view within the FlutterView shouldn't affect the coordinates,
1802 // since the framework sends us global coordinates.
1803 XCTAssertTrue(CGRectEqualToRect(CGRectMake(-306 - 113, 3 - 119, 300, 300),
1804 [inputView firstRectForRange:range]));
1805}
1806
1807- (void)testFirstRectForRangeReturnsNoneZeroRectWhenScribbleIsEnabled {
1808 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1809 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1810
1811 FlutterTextInputView* mockInputView = OCMPartialMock(inputView);
1812 OCMStub([mockInputView isScribbleAvailable]).andReturn(YES);
1813
1814 [inputView setSelectionRects:@[
1815 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1816 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1817 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1818 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1819 ]];
1820
1821 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1822
1823 if (@available(iOS 17, *)) {
1824 XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1825 [inputView firstRectForRange:multiRectRange]));
1826 } else {
1827 XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100),
1828 [inputView firstRectForRange:multiRectRange]));
1829 }
1830}
1831
1832- (void)testFirstRectForRangeReturnsCorrectRectOnASingleLineLeftToRight {
1833 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1834 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1835
1836 [inputView setSelectionRects:@[
1837 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1838 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1839 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1840 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1841 ]];
1842 FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1843 if (@available(iOS 17, *)) {
1844 XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100),
1845 [inputView firstRectForRange:singleRectRange]));
1846 } else {
1847 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1848 }
1849
1850 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1851
1852 if (@available(iOS 17, *)) {
1853 XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1854 [inputView firstRectForRange:multiRectRange]));
1855 } else {
1856 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1857 }
1858
1859 [inputView setTextInputState:@{@"text" : @"COM"}];
1860 FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)];
1861 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds]));
1862}
1863
1864- (void)testFirstRectForRangeReturnsCorrectRectOnASingleLineRightToLeft {
1865 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1866 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1867
1868 [inputView setSelectionRects:@[
1869 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1870 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U],
1871 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U],
1872 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1873 ]];
1874 FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1875 if (@available(iOS 17, *)) {
1876 XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100),
1877 [inputView firstRectForRange:singleRectRange]));
1878 } else {
1879 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1880 }
1881
1882 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1883 if (@available(iOS 17, *)) {
1884 XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100),
1885 [inputView firstRectForRange:multiRectRange]));
1886 } else {
1887 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1888 }
1889
1890 [inputView setTextInputState:@{@"text" : @"COM"}];
1891 FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)];
1892 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds]));
1893}
1894
1895- (void)testFirstRectForRangeReturnsCorrectRectOnMultipleLinesLeftToRight {
1896 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1897 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1898
1899 [inputView setSelectionRects:@[
1900 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1901 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1902 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1903 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1904 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:4U],
1905 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:5U],
1906 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:6U],
1907 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 100, 100, 100) position:7U],
1908 ]];
1909 FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1910 if (@available(iOS 17, *)) {
1911 XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100),
1912 [inputView firstRectForRange:singleRectRange]));
1913 } else {
1914 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1915 }
1916
1917 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1918
1919 if (@available(iOS 17, *)) {
1920 XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1921 [inputView firstRectForRange:multiRectRange]));
1922 } else {
1923 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1924 }
1925}
1926
1927- (void)testFirstRectForRangeReturnsCorrectRectOnMultipleLinesRightToLeft {
1928 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1929 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1930
1931 [inputView setSelectionRects:@[
1932 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1933 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U],
1934 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U],
1935 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1936 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 100, 100, 100) position:4U],
1937 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:5U],
1938 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:6U],
1939 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:7U],
1940 ]];
1941 FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1942 if (@available(iOS 17, *)) {
1943 XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100),
1944 [inputView firstRectForRange:singleRectRange]));
1945 } else {
1946 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1947 }
1948
1949 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1950 if (@available(iOS 17, *)) {
1951 XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100),
1952 [inputView firstRectForRange:multiRectRange]));
1953 } else {
1954 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1955 }
1956}
1957
1958- (void)testFirstRectForRangeReturnsCorrectRectOnSingleLineWithVaryingMinYAndMaxYLeftToRight {
1959 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1960 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1961
1962 [inputView setSelectionRects:@[
1963 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1964 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 10, 100, 80)
1965 position:1U], // shorter
1966 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, -10, 100, 120)
1967 position:2U], // taller
1968 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1969 ]];
1970
1971 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1972
1973 if (@available(iOS 17, *)) {
1974 XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, -10, 300, 120),
1975 [inputView firstRectForRange:multiRectRange]));
1976 } else {
1977 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1978 }
1979}
1980
1981- (void)testFirstRectForRangeReturnsCorrectRectOnSingleLineWithVaryingMinYAndMaxYRightToLeft {
1982 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1983 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1984
1985 [inputView setSelectionRects:@[
1986 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1987 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, -10, 100, 120)
1988 position:1U], // taller
1989 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 10, 100, 80)
1990 position:2U], // shorter
1991 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1992 ]];
1993
1994 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1995
1996 if (@available(iOS 17, *)) {
1997 XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, -10, 300, 120),
1998 [inputView firstRectForRange:multiRectRange]));
1999 } else {
2000 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
2001 }
2002}
2003
2004- (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsExceedingThresholdLeftToRight {
2005 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2006 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2007
2008 [inputView setSelectionRects:@[
2009 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2010 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
2011 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
2012 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
2013 // y=60 exceeds threshold, so treat it as a new line.
2014 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 60, 100, 100) position:4U],
2015 ]];
2016
2017 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
2018
2019 if (@available(iOS 17, *)) {
2020 XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
2021 [inputView firstRectForRange:multiRectRange]));
2022 } else {
2023 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
2024 }
2025}
2026
2027- (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsExceedingThresholdRightToLeft {
2028 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2029 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2030
2031 [inputView setSelectionRects:@[
2032 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
2033 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U],
2034 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U],
2035 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
2036 // y=60 exceeds threshold, so treat it as a new line.
2037 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 60, 100, 100) position:4U],
2038 ]];
2039
2040 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
2041
2042 if (@available(iOS 17, *)) {
2043 XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100),
2044 [inputView firstRectForRange:multiRectRange]));
2045 } else {
2046 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
2047 }
2048}
2049
2050- (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsWithinThresholdLeftToRight {
2051 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2052 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2053
2054 [inputView setSelectionRects:@[
2055 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2056 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
2057 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
2058 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
2059 // y=40 is within line threshold, so treat it as the same line
2060 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(400, 40, 100, 100) position:4U],
2061 ]];
2062
2063 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
2064
2065 if (@available(iOS 17, *)) {
2066 XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 400, 140),
2067 [inputView firstRectForRange:multiRectRange]));
2068 } else {
2069 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
2070 }
2071}
2072
2073- (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsWithinThresholdRightToLeft {
2074 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2075 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2076
2077 [inputView setSelectionRects:@[
2078 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(400, 0, 100, 100) position:0U],
2079 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:1U],
2080 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
2081 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:3U],
2082 // y=40 is within line threshold, so treat it as the same line
2083 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 40, 100, 100) position:4U],
2084 ]];
2085
2086 FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
2087
2088 if (@available(iOS 17, *)) {
2089 XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 400, 140),
2090 [inputView firstRectForRange:multiRectRange]));
2091 } else {
2092 XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
2093 }
2094}
2095
2096- (void)testClosestPositionToPoint {
2097 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2098 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2099
2100 // Minimize the vertical distance from the center of the rects first
2101 [inputView setSelectionRects:@[
2102 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2103 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2104 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:2U],
2105 ]];
2106 CGPoint point = CGPointMake(150, 150);
2107 XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2108 XCTAssertEqual(UITextStorageDirectionBackward,
2109 ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2110
2111 // Then, if the point is above the bottom of the closest rects vertically, get the closest x
2112 // origin
2113 [inputView setSelectionRects:@[
2114 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2115 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2116 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2117 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2118 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
2119 ]];
2120 point = CGPointMake(125, 150);
2121 XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2122 XCTAssertEqual(UITextStorageDirectionForward,
2123 ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2124
2125 // However, if the point is below the bottom of the closest rects vertically, get the position
2126 // farthest to the right
2127 [inputView setSelectionRects:@[
2128 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2129 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2130 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2131 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2132 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 300, 100, 100) position:4U],
2133 ]];
2134 point = CGPointMake(125, 201);
2135 XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2136 XCTAssertEqual(UITextStorageDirectionBackward,
2137 ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2138
2139 // Also check a point at the right edge of the last selection rect
2140 [inputView setSelectionRects:@[
2141 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2142 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2143 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2144 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2145 ]];
2146 point = CGPointMake(125, 250);
2147 XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2148 XCTAssertEqual(UITextStorageDirectionBackward,
2149 ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2150
2151 // Minimize vertical distance if the difference is more than 1 point.
2152 [inputView setSelectionRects:@[
2153 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 2, 100, 100) position:0U],
2154 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 2, 100, 100) position:1U],
2155 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
2156 ]];
2157 point = CGPointMake(110, 50);
2158 XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2159 XCTAssertEqual(UITextStorageDirectionForward,
2160 ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2161
2162 // In floating cursor mode, the vertical difference is allowed to be 10 points.
2163 // The closest horizontal position will now win.
2164 [inputView beginFloatingCursorAtPoint:CGPointZero];
2165 XCTAssertEqual(1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2166 XCTAssertEqual(UITextStorageDirectionForward,
2167 ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2168 [inputView endFloatingCursor];
2169}
2170
2171- (void)testClosestPositionToPointRTL {
2172 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2173 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2174
2175 [inputView setSelectionRects:@[
2176 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100)
2177 position:0U
2178 writingDirection:NSWritingDirectionRightToLeft],
2179 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100)
2180 position:1U
2181 writingDirection:NSWritingDirectionRightToLeft],
2182 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100)
2183 position:2U
2184 writingDirection:NSWritingDirectionRightToLeft],
2185 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100)
2186 position:3U
2187 writingDirection:NSWritingDirectionRightToLeft],
2188 ]];
2189 FlutterTextPosition* position =
2190 (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(275, 50)];
2191 XCTAssertEqual(0U, position.index);
2192 XCTAssertEqual(UITextStorageDirectionForward, position.affinity);
2193 position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(225, 50)];
2194 XCTAssertEqual(1U, position.index);
2195 XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
2196 position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(175, 50)];
2197 XCTAssertEqual(1U, position.index);
2198 XCTAssertEqual(UITextStorageDirectionForward, position.affinity);
2199 position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(125, 50)];
2200 XCTAssertEqual(2U, position.index);
2201 XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
2202 position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(75, 50)];
2203 XCTAssertEqual(2U, position.index);
2204 XCTAssertEqual(UITextStorageDirectionForward, position.affinity);
2205 position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(25, 50)];
2206 XCTAssertEqual(3U, position.index);
2207 XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
2208 position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(-25, 50)];
2209 XCTAssertEqual(3U, position.index);
2210 XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
2211}
2212
2213- (void)testSelectionRectsForRange {
2214 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2215 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2216
2217 CGRect testRect0 = CGRectMake(100, 100, 100, 100);
2218 CGRect testRect1 = CGRectMake(200, 200, 100, 100);
2219 [inputView setSelectionRects:@[
2220 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2223 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U],
2224 ]];
2225
2226 // Returns the matching rects within a range
2227 FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 2)];
2228 XCTAssertTrue(CGRectEqualToRect(testRect0, [inputView selectionRectsForRange:range][0].rect));
2229 XCTAssertTrue(CGRectEqualToRect(testRect1, [inputView selectionRectsForRange:range][1].rect));
2230 XCTAssertEqual(2U, [[inputView selectionRectsForRange:range] count]);
2231
2232 // Returns a 0 width rect for a 0-length range
2233 range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 0)];
2234 XCTAssertEqual(1U, [[inputView selectionRectsForRange:range] count]);
2235 XCTAssertTrue(CGRectEqualToRect(
2236 CGRectMake(testRect0.origin.x, testRect0.origin.y, 0, testRect0.size.height),
2237 [inputView selectionRectsForRange:range][0].rect));
2238}
2239
2240- (void)testClosestPositionToPointWithinRange {
2241 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2242 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2243
2244 // Do not return a position before the start of the range
2245 [inputView setSelectionRects:@[
2246 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2247 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2248 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2249 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2250 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
2251 ]];
2252 CGPoint point = CGPointMake(125, 150);
2253 FlutterTextRange* range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(3, 2)] copy];
2254 XCTAssertEqual(
2255 3U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index);
2256 XCTAssertEqual(
2257 UITextStorageDirectionForward,
2258 ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).affinity);
2259
2260 // Do not return a position after the end of the range
2261 [inputView setSelectionRects:@[
2262 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2263 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2264 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2265 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2266 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
2267 ]];
2268 point = CGPointMake(125, 150);
2269 range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] copy];
2270 XCTAssertEqual(
2271 1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index);
2272 XCTAssertEqual(
2273 UITextStorageDirectionForward,
2274 ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).affinity);
2275}
2276
2277- (void)testClosestPositionToPointWithPartialSelectionRects {
2278 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2279 [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2280
2281 [inputView setSelectionRects:@[ [FlutterTextSelectionRect
2282 selectionRectWithRect:CGRectMake(0, 0, 100, 100)
2283 position:0U] ]];
2284 // Asking with a position at the end of selection rects should give you the trailing edge of
2285 // the last rect.
2286 XCTAssertTrue(CGRectEqualToRect(
2288 positionWithIndex:1
2289 affinity:UITextStorageDirectionForward]],
2290 CGRectMake(100, 0, 0, 100)));
2291 // Asking with a position beyond the end of selection rects should return CGRectZero without
2292 // crashing.
2293 XCTAssertTrue(CGRectEqualToRect(
2295 positionWithIndex:2
2296 affinity:UITextStorageDirectionForward]],
2297 CGRectZero));
2298}
2299
2300#pragma mark - Floating Cursor - Tests
2301
2302- (void)testFloatingCursorDoesNotThrow {
2303 // The keyboard implementation may send unbalanced calls to the input view.
2304 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2305 [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2306 [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2307 [inputView endFloatingCursor];
2308 [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2309 [inputView endFloatingCursor];
2310}
2311
2312- (void)testFloatingCursor {
2313 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2314 [inputView setTextInputState:@{
2315 @"text" : @"test",
2316 @"selectionBase" : @1,
2317 @"selectionExtent" : @1,
2318 }];
2319
2321 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U];
2322 FlutterTextSelectionRect* second =
2323 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:1U];
2325 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U];
2326 FlutterTextSelectionRect* fourth =
2327 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U];
2328 [inputView setSelectionRects:@[ first, second, third, fourth ]];
2329
2330 // Verify zeroth caret rect is based on left edge of first character.
2331 XCTAssertTrue(CGRectEqualToRect(
2333 positionWithIndex:0
2334 affinity:UITextStorageDirectionForward]],
2335 CGRectMake(0, 0, 0, 100)));
2336 // Since the textAffinity is downstream, the caret rect will be based on the
2337 // left edge of the succeeding character.
2338 XCTAssertTrue(CGRectEqualToRect(
2340 positionWithIndex:1
2341 affinity:UITextStorageDirectionForward]],
2342 CGRectMake(100, 100, 0, 100)));
2343 XCTAssertTrue(CGRectEqualToRect(
2345 positionWithIndex:2
2346 affinity:UITextStorageDirectionForward]],
2347 CGRectMake(200, 200, 0, 100)));
2348 XCTAssertTrue(CGRectEqualToRect(
2350 positionWithIndex:3
2351 affinity:UITextStorageDirectionForward]],
2352 CGRectMake(300, 300, 0, 100)));
2353 // There is no subsequent character for the last position, so the caret rect
2354 // will be based on the right edge of the preceding character.
2355 XCTAssertTrue(CGRectEqualToRect(
2357 positionWithIndex:4
2358 affinity:UITextStorageDirectionForward]],
2359 CGRectMake(400, 300, 0, 100)));
2360 // Verify no caret rect for out-of-range character.
2361 XCTAssertTrue(CGRectEqualToRect(
2363 positionWithIndex:5
2364 affinity:UITextStorageDirectionForward]],
2365 CGRectZero));
2366
2367 // Check caret rects again again when text affinity is upstream.
2368 [inputView setTextInputState:@{
2369 @"text" : @"test",
2370 @"selectionBase" : @2,
2371 @"selectionExtent" : @2,
2372 }];
2373 // Verify zeroth caret rect is based on left edge of first character.
2374 XCTAssertTrue(CGRectEqualToRect(
2376 positionWithIndex:0
2377 affinity:UITextStorageDirectionBackward]],
2378 CGRectMake(0, 0, 0, 100)));
2379 // Since the textAffinity is upstream, all below caret rects will be based on
2380 // the right edge of the preceding character.
2381 XCTAssertTrue(CGRectEqualToRect(
2383 positionWithIndex:1
2384 affinity:UITextStorageDirectionBackward]],
2385 CGRectMake(100, 0, 0, 100)));
2386 XCTAssertTrue(CGRectEqualToRect(
2388 positionWithIndex:2
2389 affinity:UITextStorageDirectionBackward]],
2390 CGRectMake(200, 100, 0, 100)));
2391 XCTAssertTrue(CGRectEqualToRect(
2393 positionWithIndex:3
2394 affinity:UITextStorageDirectionBackward]],
2395 CGRectMake(300, 200, 0, 100)));
2396 XCTAssertTrue(CGRectEqualToRect(
2398 positionWithIndex:4
2399 affinity:UITextStorageDirectionBackward]],
2400 CGRectMake(400, 300, 0, 100)));
2401 // Verify no caret rect for out-of-range character.
2402 XCTAssertTrue(CGRectEqualToRect(
2404 positionWithIndex:5
2405 affinity:UITextStorageDirectionBackward]],
2406 CGRectZero));
2407
2408 // Verify floating cursor updates are relative to original position, and that there is no bounds
2409 // change.
2410 CGRect initialBounds = inputView.bounds;
2411 [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2412 XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
2413 OCMVerify([engine flutterTextInputView:inputView
2414 updateFloatingCursor:FlutterFloatingCursorDragStateStart
2415 withClient:0
2416 withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
2417 return ([state[@"X"] isEqualToNumber:@(0)]) &&
2418 ([state[@"Y"] isEqualToNumber:@(0)]);
2419 }]]);
2420
2421 [inputView updateFloatingCursorAtPoint:CGPointMake(456, 654)];
2422 XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
2423 OCMVerify([engine flutterTextInputView:inputView
2424 updateFloatingCursor:FlutterFloatingCursorDragStateUpdate
2425 withClient:0
2426 withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
2427 return ([state[@"X"] isEqualToNumber:@(333)]) &&
2428 ([state[@"Y"] isEqualToNumber:@(333)]);
2429 }]]);
2430
2431 [inputView endFloatingCursor];
2432 XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
2433 OCMVerify([engine flutterTextInputView:inputView
2434 updateFloatingCursor:FlutterFloatingCursorDragStateEnd
2435 withClient:0
2436 withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
2437 return ([state[@"X"] isEqualToNumber:@(0)]) &&
2438 ([state[@"Y"] isEqualToNumber:@(0)]);
2439 }]]);
2440}
2441
2442#pragma mark - UIKeyInput Overrides - Tests
2443
2444- (void)testInsertTextAddsPlaceholderSelectionRects {
2445 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2446 [inputView
2447 setTextInputState:@{@"text" : @"test", @"selectionBase" : @1, @"selectionExtent" : @1}];
2448
2450 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U];
2451 FlutterTextSelectionRect* second =
2452 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:1U];
2454 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U];
2455 FlutterTextSelectionRect* fourth =
2456 [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U];
2457 [inputView setSelectionRects:@[ first, second, third, fourth ]];
2458
2459 // Inserts additional selection rects at the selection start
2460 [inputView insertText:@"in"];
2461 NSArray* selectionRects =
2462 [inputView selectionRectsForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 6)]];
2463 XCTAssertEqual(6U, [selectionRects count]);
2464
2465 XCTAssertEqual(first.position, ((FlutterTextSelectionRect*)selectionRects[0]).position);
2466 XCTAssertTrue(CGRectEqualToRect(first.rect, ((FlutterTextSelectionRect*)selectionRects[0]).rect));
2467
2468 XCTAssertEqual(second.position, ((FlutterTextSelectionRect*)selectionRects[1]).position);
2469 XCTAssertTrue(
2470 CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[1]).rect));
2471
2472 XCTAssertEqual(second.position + 1, ((FlutterTextSelectionRect*)selectionRects[2]).position);
2473 XCTAssertTrue(
2474 CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[2]).rect));
2475
2476 XCTAssertEqual(second.position + 2, ((FlutterTextSelectionRect*)selectionRects[3]).position);
2477 XCTAssertTrue(
2478 CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[3]).rect));
2479
2480 XCTAssertEqual(third.position + 2, ((FlutterTextSelectionRect*)selectionRects[4]).position);
2481 XCTAssertTrue(CGRectEqualToRect(third.rect, ((FlutterTextSelectionRect*)selectionRects[4]).rect));
2482
2483 XCTAssertEqual(fourth.position + 2, ((FlutterTextSelectionRect*)selectionRects[5]).position);
2484 XCTAssertTrue(
2485 CGRectEqualToRect(fourth.rect, ((FlutterTextSelectionRect*)selectionRects[5]).rect));
2486}
2487
2488#pragma mark - Autofill - Utilities
2489
2490- (NSMutableDictionary*)mutablePasswordTemplateCopy {
2491 if (!_passwordTemplate) {
2493 @"inputType" : @{@"name" : @"TextInuptType.text"},
2494 @"keyboardAppearance" : @"Brightness.light",
2495 @"obscureText" : @YES,
2496 @"inputAction" : @"TextInputAction.unspecified",
2497 @"smartDashesType" : @"0",
2498 @"smartQuotesType" : @"0",
2499 @"autocorrect" : @YES
2500 };
2501 }
2502
2503 return [_passwordTemplate mutableCopy];
2504}
2505
2506- (NSArray<FlutterTextInputView*>*)viewsVisibleToAutofill {
2507 return [self.installedInputViews
2508 filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisibleToAutofill == YES"]];
2509}
2510
2511- (void)commitAutofillContextAndVerify {
2512 FlutterMethodCall* methodCall =
2513 [FlutterMethodCall methodCallWithMethodName:@"TextInput.finishAutofillContext"
2514 arguments:@YES];
2515 [textInputPlugin handleMethodCall:methodCall
2516 result:^(id _Nullable result){
2517 }];
2518
2519 XCTAssertEqual(self.viewsVisibleToAutofill.count,
2520 [textInputPlugin.activeView isVisibleToAutofill] ? 1ul : 0ul);
2521 XCTAssertNotEqual(textInputPlugin.textInputView, nil);
2522 // The active view should still be installed so it doesn't get
2523 // deallocated.
2524 XCTAssertEqual(self.installedInputViews.count, 1ul);
2525 XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2526}
2527
2528#pragma mark - Autofill - Tests
2529
2530- (void)testDisablingAutofillOnInputClient {
2531 NSDictionary* config = self.mutableTemplateCopy;
2532 [config setValue:@"YES" forKey:@"obscureText"];
2533
2534 [self setClientId:123 configuration:config];
2535
2536 FlutterTextInputView* inputView = self.installedInputViews[0];
2537 XCTAssertEqualObjects(inputView.textContentType, @"");
2538}
2539
2540- (void)testAutofillEnabledByDefault {
2541 NSDictionary* config = self.mutableTemplateCopy;
2542 [config setValue:@"NO" forKey:@"obscureText"];
2543 [config setValue:@{@"uniqueIdentifier" : @"field1", @"editingValue" : @{@"text" : @""}}
2544 forKey:@"autofill"];
2545
2546 [self setClientId:123 configuration:config];
2547
2548 FlutterTextInputView* inputView = self.installedInputViews[0];
2549 XCTAssertNil(inputView.textContentType);
2550}
2551
2552- (void)testAutofillContext {
2553 NSMutableDictionary* field1 = self.mutableTemplateCopy;
2554
2555 [field1 setValue:@{
2556 @"uniqueIdentifier" : @"field1",
2557 @"hints" : @[ @"hint1" ],
2558 @"editingValue" : @{@"text" : @""}
2559 }
2560 forKey:@"autofill"];
2561
2562 NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2563 [field2 setValue:@{
2564 @"uniqueIdentifier" : @"field2",
2565 @"hints" : @[ @"hint2" ],
2566 @"editingValue" : @{@"text" : @""}
2567 }
2568 forKey:@"autofill"];
2569
2570 NSMutableDictionary* config = [field1 mutableCopy];
2571 [config setValue:@[ field1, field2 ] forKey:@"fields"];
2572
2573 [self setClientId:123 configuration:config];
2574 XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2575
2576 XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2577
2578 [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2579 XCTAssertEqual(self.installedInputViews.count, 2ul);
2580 XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2581 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2582
2583 // The configuration changes.
2584 NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy;
2585 [field3 setValue:@{
2586 @"uniqueIdentifier" : @"field3",
2587 @"hints" : @[ @"hint3" ],
2588 @"editingValue" : @{@"text" : @""}
2589 }
2590 forKey:@"autofill"];
2591
2592 NSMutableDictionary* oldContext = textInputPlugin.autofillContext;
2593 // Replace field2 with field3.
2594 [config setValue:@[ field1, field3 ] forKey:@"fields"];
2595
2596 [self setClientId:123 configuration:config];
2597
2598 XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2599 XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
2600
2601 [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2602 XCTAssertEqual(self.installedInputViews.count, 3ul);
2603 XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2604 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2605
2606 // Old autofill input fields are still installed and reused.
2607 for (NSString* key in oldContext.allKeys) {
2608 XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
2609 }
2610
2611 // Switch to a password field that has no contentType and is not in an AutofillGroup.
2612 config = self.mutablePasswordTemplateCopy;
2613
2614 oldContext = textInputPlugin.autofillContext;
2615 [self setClientId:124 configuration:config];
2616 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2617
2618 XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
2619 XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
2620
2621 [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2622 XCTAssertEqual(self.installedInputViews.count, 4ul);
2623
2624 // Old autofill input fields are still installed and reused.
2625 for (NSString* key in oldContext.allKeys) {
2626 XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
2627 }
2628 // The active view should change.
2629 XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2630 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2631
2632 // Switch to a similar password field, the previous field should be reused.
2633 oldContext = textInputPlugin.autofillContext;
2634 [self setClientId:200 configuration:config];
2635
2636 // Reuse the input view instance from the last time.
2637 XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
2638 XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
2639
2640 [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2641 XCTAssertEqual(self.installedInputViews.count, 4ul);
2642
2643 // Old autofill input fields are still installed and reused.
2644 for (NSString* key in oldContext.allKeys) {
2645 XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
2646 }
2647 XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2648 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2649}
2650
2651- (void)testCommitAutofillContext {
2652 NSMutableDictionary* field1 = self.mutableTemplateCopy;
2653 [field1 setValue:@{
2654 @"uniqueIdentifier" : @"field1",
2655 @"hints" : @[ @"hint1" ],
2656 @"editingValue" : @{@"text" : @""}
2657 }
2658 forKey:@"autofill"];
2659
2660 NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2661 [field2 setValue:@{
2662 @"uniqueIdentifier" : @"field2",
2663 @"hints" : @[ @"hint2" ],
2664 @"editingValue" : @{@"text" : @""}
2665 }
2666 forKey:@"autofill"];
2667
2668 NSMutableDictionary* field3 = self.mutableTemplateCopy;
2669 [field3 setValue:@{
2670 @"uniqueIdentifier" : @"field3",
2671 @"hints" : @[ @"hint3" ],
2672 @"editingValue" : @{@"text" : @""}
2673 }
2674 forKey:@"autofill"];
2675
2676 NSMutableDictionary* config = [field1 mutableCopy];
2677 [config setValue:@[ field1, field2 ] forKey:@"fields"];
2678
2679 [self setClientId:123 configuration:config];
2680 XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2681 XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2682 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2683
2684 [self commitAutofillContextAndVerify];
2685 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2686
2687 // Install the password field again.
2688 [self setClientId:123 configuration:config];
2689 // Switch to a regular autofill group.
2690 [self setClientId:124 configuration:field3];
2691 XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
2692
2693 [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2694 XCTAssertEqual(self.installedInputViews.count, 3ul);
2695 XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2696 XCTAssertNotEqual(textInputPlugin.textInputView, nil);
2697 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2698
2699 [self commitAutofillContextAndVerify];
2700 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2701
2702 // Now switch to an input field that does not autofill.
2703 [self setClientId:125 configuration:self.mutableTemplateCopy];
2704
2705 XCTAssertEqual(self.viewsVisibleToAutofill.count, 0ul);
2706 // The active view should still be installed so it doesn't get
2707 // deallocated.
2708
2709 [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2710 XCTAssertEqual(self.installedInputViews.count, 1ul);
2711 XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2712 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2713
2714 [self commitAutofillContextAndVerify];
2715 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2716}
2717
2718- (void)testAutofillInputViews {
2719 NSMutableDictionary* field1 = self.mutableTemplateCopy;
2720 [field1 setValue:@{
2721 @"uniqueIdentifier" : @"field1",
2722 @"hints" : @[ @"hint1" ],
2723 @"editingValue" : @{@"text" : @""}
2724 }
2725 forKey:@"autofill"];
2726
2727 NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2728 [field2 setValue:@{
2729 @"uniqueIdentifier" : @"field2",
2730 @"hints" : @[ @"hint2" ],
2731 @"editingValue" : @{@"text" : @""}
2732 }
2733 forKey:@"autofill"];
2734
2735 NSMutableDictionary* config = [field1 mutableCopy];
2736 [config setValue:@[ field1, field2 ] forKey:@"fields"];
2737
2738 [self setClientId:123 configuration:config];
2739 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2740
2741 // Find all the FlutterTextInputViews we created.
2742 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
2743
2744 // Both fields are installed and visible because it's a password group.
2745 XCTAssertEqual(inputFields.count, 2ul);
2746 XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2747
2748 // Find the inactive autofillable input field.
2749 FlutterTextInputView* inactiveView = inputFields[1];
2750 [inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)]
2751 withText:@"Autofilled!"];
2752 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2753
2754 // Verify behavior.
2755 OCMVerify([engine flutterTextInputView:inactiveView
2756 updateEditingClient:0
2757 withState:[OCMArg isNotNil]
2758 withTag:@"field2"]);
2759}
2760
2761- (void)testAutofillContextPersistsAfterClearClient {
2762 NSMutableDictionary* field1 = self.mutableTemplateCopy;
2763 [field1 setValue:@{
2764 @"uniqueIdentifier" : @"field1",
2765 @"hints" : @[ @"username" ],
2766 @"editingValue" : @{@"text" : @""}
2767 }
2768 forKey:@"autofill"];
2769
2770 NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2771 [field2 setValue:@{
2772 @"uniqueIdentifier" : @"field2",
2773 @"hints" : @[ @"password" ],
2774 @"editingValue" : @{@"text" : @""}
2775 }
2776 forKey:@"autofill"];
2777
2778 NSMutableDictionary* config = [field1 mutableCopy];
2779 [config setValue:@[ field1, field2 ] forKey:@"fields"];
2780
2781 // Verify initial state.
2782 [self setClientId:123 configuration:config];
2783 XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2784 XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
2785
2786 // Retain autofill context.
2787 [self setClientClear];
2788 XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2789 XCTAssertTrue(textInputPlugin.pendingAutofillRemoval);
2790
2791 // Consume autofill context.
2792 [self commitAutofillContextAndVerify];
2793 XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2794 XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
2795}
2796
2797- (void)testSetClientResetsPendingAutofillRemoval {
2798 // When autofill context exists and clearClient sets pendingAutofillRemoval,
2799 // a subsequent setClient should reset the flag because a new client is
2800 // connecting and the deferred removal is no longer needed.
2801 NSMutableDictionary* field = self.mutablePasswordTemplateCopy;
2802 [field setValue:@{
2803 @"uniqueIdentifier" : @"field1",
2804 @"hints" : @[ @"password" ],
2805 @"editingValue" : @{@"text" : @""}
2806 }
2807 forKey:@"autofill"];
2808 [field setValue:@[ field ] forKey:@"fields"];
2809
2810 // Set up autofill context.
2811 [self setClientId:123 configuration:field];
2812 XCTAssertGreaterThan(textInputPlugin.autofillContext.count, 0ul);
2813
2814 // clearClient with autofill context sets pendingAutofillRemoval.
2815 [self setClientClear];
2816 XCTAssertTrue(textInputPlugin.pendingAutofillRemoval);
2817
2818 // A new setClient resets the pending autofill removal flag.
2819 [self setClientId:456 configuration:self.mutableTemplateCopy];
2820 XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
2821 XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2822}
2823
2824- (void)testPendingInputViewRemovalAfterClearClient {
2825 // When autofillContext is empty and the view is first responder,
2826 // clearClient should set pendingInputViewRemoval,
2827 // and hideTextInput should consume it.
2828 NSDictionary* config = self.mutableTemplateCopy;
2829
2830 // Verify initial state.
2831 [self setClientId:123 configuration:config];
2832 XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2833 XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2834
2835 // Stub isFirstResponder to simulate the view being first responder.
2836 // In the test environment, becomeFirstResponder does not work.
2837 id mockActiveView = OCMPartialMock(textInputPlugin.activeView);
2838 OCMStub([mockActiveView isFirstResponder]).andReturn(YES);
2839
2840 // clearClient with no autofill context sets pendingInputViewRemoval.
2841 [self setClientClear];
2842 XCTAssertTrue(textInputPlugin.pendingInputViewRemoval);
2843 XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
2844
2845 // hideTextInput consumes the flag and removes the view.
2846 [self setTextInputHide];
2847 XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2848 XCTAssertNil(textInputPlugin.activeView.superview);
2849}
2850
2851- (void)testHideBeforeClearClientRemovesViewImmediately {
2852 // When hideTextInput is called before clearTextInputClient,
2853 // the view is no longer first responder. clearClient should
2854 // remove the view immediately since no keyboard dismiss animation is needed.
2855 NSDictionary* config = self.mutableTemplateCopy;
2856
2857 [self setClientId:123 configuration:config];
2858 [self setTextInputShow];
2859 XCTAssertNotNil(textInputPlugin.activeView.superview);
2860
2861 // Hide first: resignFirstResponder, but no removeFromSuperview.
2862 [self setTextInputHide];
2863 XCTAssertNotNil(textInputPlugin.activeView.superview);
2864
2865 // clearClient after hide: view is not first responder,
2866 // so removeFromSuperview should happen immediately.
2867 [self setClientClear];
2868 XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2869 XCTAssertNil(textInputPlugin.activeView.superview);
2870}
2871
2872- (void)testSetClientResetsPendingInputViewRemoval {
2873 // When clearClient sets pendingInputViewRemoval (no autofill, first responder),
2874 // a subsequent setClient should reset the flag because a new client is
2875 // connecting and the deferred removal is no longer needed.
2876 NSDictionary* config = self.mutableTemplateCopy;
2877
2878 // Field 1: setClient → show → clearClient (sets pendingInputViewRemoval).
2879 [self setClientId:123 configuration:config];
2880 [self setTextInputShow];
2881
2882 // Stub isFirstResponder to simulate the view being first responder.
2883 // In the test environment, becomeFirstResponder does not work.
2884 id mockActiveView = OCMPartialMock(textInputPlugin.activeView);
2885 OCMStub([mockActiveView isFirstResponder]).andReturn(YES);
2886
2887 [self setClientClear];
2888 XCTAssertTrue(textInputPlugin.pendingInputViewRemoval);
2889
2890 // Field 2: setClient resets the stale flag.
2891 [self setClientId:456 configuration:config];
2892 XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2893}
2894
2895- (void)testPasswordAutofillHack {
2896 NSDictionary* config = self.mutableTemplateCopy;
2897 [config setValue:@"YES" forKey:@"obscureText"];
2898 [self setClientId:123 configuration:config];
2899
2900 // Find all the FlutterTextInputViews we created.
2901 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
2902
2903 FlutterTextInputView* inputView = inputFields[0];
2904
2905 XCTAssert([inputView isKindOfClass:[UITextField class]]);
2906 // FlutterSecureTextInputView does not respond to font,
2907 // but it should return the default UITextField.font.
2908 XCTAssertNotEqual([inputView performSelector:@selector(font)], nil);
2909}
2910
2911- (void)testClearAutofillContextClearsSelection {
2912 NSMutableDictionary* regularField = self.mutableTemplateCopy;
2913 NSDictionary* editingValue = @{
2914 @"text" : @"REGULAR_TEXT_FIELD",
2915 @"composingBase" : @0,
2916 @"composingExtent" : @3,
2917 @"selectionBase" : @1,
2918 @"selectionExtent" : @4
2919 };
2920 [regularField setValue:@{
2921 @"uniqueIdentifier" : @"field2",
2922 @"hints" : @[ @"hint2" ],
2923 @"editingValue" : editingValue,
2924 }
2925 forKey:@"autofill"];
2926 [regularField addEntriesFromDictionary:editingValue];
2927 [self setClientId:123 configuration:regularField];
2928 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2929 XCTAssertEqual(self.installedInputViews.count, 1ul);
2930
2931 FlutterTextInputView* oldInputView = self.installedInputViews[0];
2932 XCTAssert([oldInputView.text isEqualToString:@"REGULAR_TEXT_FIELD"]);
2933 FlutterTextRange* selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
2934 XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(1, 3)));
2935
2936 // Replace the original password field with new one. This should remove
2937 // the old password field, but not immediately.
2938 [self setClientId:124 configuration:self.mutablePasswordTemplateCopy];
2939 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2940
2941 XCTAssertEqual(self.installedInputViews.count, 2ul);
2942
2943 [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2944 XCTAssertEqual(self.installedInputViews.count, 1ul);
2945
2946 // Verify the old input view is properly cleaned up.
2947 XCTAssert([oldInputView.text isEqualToString:@""]);
2948 selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
2949 XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(0, 0)));
2950}
2951
2952- (void)testGarbageInputViewsAreNotRemovedImmediately {
2953 // Add a password field that should autofill.
2954 [self setClientId:123 configuration:self.mutablePasswordTemplateCopy];
2955 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2956
2957 XCTAssertEqual(self.installedInputViews.count, 1ul);
2958 // Add an input field that doesn't autofill. This should remove the password
2959 // field, but not immediately.
2960 [self setClientId:124 configuration:self.mutableTemplateCopy];
2961 [self ensureOnlyActiveViewCanBecomeFirstResponder];
2962
2963 XCTAssertEqual(self.installedInputViews.count, 2ul);
2964
2965 [self commitAutofillContextAndVerify];
2966}
2967
2968- (void)testScribbleSetSelectionRects {
2969 NSMutableDictionary* regularField = self.mutableTemplateCopy;
2970 NSDictionary* editingValue = @{
2971 @"text" : @"REGULAR_TEXT_FIELD",
2972 @"composingBase" : @0,
2973 @"composingExtent" : @3,
2974 @"selectionBase" : @1,
2975 @"selectionExtent" : @4
2976 };
2977 [regularField setValue:@{
2978 @"uniqueIdentifier" : @"field1",
2979 @"hints" : @[ @"hint2" ],
2980 @"editingValue" : editingValue,
2981 }
2982 forKey:@"autofill"];
2983 [regularField addEntriesFromDictionary:editingValue];
2984 [self setClientId:123 configuration:regularField];
2985 XCTAssertEqual(self.installedInputViews.count, 1ul);
2986 XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 0u);
2987
2988 NSArray<NSNumber*>* selectionRect = [NSArray arrayWithObjects:@0, @0, @100, @100, @0, @1, nil];
2989 NSArray* selectionRects = [NSArray arrayWithObjects:selectionRect, nil];
2990 FlutterMethodCall* methodCall =
2991 [FlutterMethodCall methodCallWithMethodName:@"Scribble.setSelectionRects"
2992 arguments:selectionRects];
2993 [textInputPlugin handleMethodCall:methodCall
2994 result:^(id _Nullable result){
2995 }];
2996
2997 XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 1u);
2998}
2999
3000- (void)testDecommissionedViewAreNotReusedByAutofill {
3001 // Regression test for https://github.com/flutter/flutter/issues/84407.
3002 NSMutableDictionary* configuration = self.mutableTemplateCopy;
3003 [configuration setValue:@{
3004 @"uniqueIdentifier" : @"field1",
3005 @"hints" : @[ UITextContentTypePassword ],
3006 @"editingValue" : @{@"text" : @""}
3007 }
3008 forKey:@"autofill"];
3009 [configuration setValue:@[ [configuration copy] ] forKey:@"fields"];
3010
3011 [self setClientId:123 configuration:configuration];
3012
3013 [self setTextInputHide];
3014 UIView* previousActiveView = textInputPlugin.activeView;
3015
3016 [self setClientId:124 configuration:configuration];
3017
3018 // Make sure the autofillable view is reused.
3019 XCTAssertEqual(previousActiveView, textInputPlugin.activeView);
3020 XCTAssertNotNil(previousActiveView);
3021 // Does not crash.
3022}
3023
3024- (void)testInitialActiveViewCantAccessTextInputDelegate {
3025 // Before the framework sends the first text input configuration,
3026 // the dummy "activeView" we use should never have access to
3027 // its textInputDelegate.
3028 XCTAssertNil(textInputPlugin.activeView.textInputDelegate);
3029}
3030
3031- (void)testAutoFillDoesNotTriggerOnShowAndHideKeyboard {
3032 // Regression test for https://github.com/flutter/flutter/issues/145681.
3033 NSMutableDictionary* configuration = self.mutableTemplateCopy;
3034 [configuration setValue:@{
3035 @"uniqueIdentifier" : @"field1",
3036 @"hints" : @[ UITextContentTypePassword ],
3037 @"editingValue" : @{@"text" : @""}
3038 }
3039 forKey:@"autofill"];
3040 [configuration setValue:@[ [configuration copy] ] forKey:@"fields"];
3041 [self setClientId:123 configuration:configuration];
3042
3043 [self setTextInputShow];
3044 XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
3045
3046 // Hiding keyboard does not trigger showing autofill prompt.
3047 [self setTextInputHide];
3048 XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
3049
3050 [self commitAutofillContextAndVerify];
3051}
3052
3053#pragma mark - Accessibility - Tests
3054
3055- (void)testUITextInputAccessibilityNotHiddenWhenKeyboardIsShownAndHidden {
3056 [self setClientId:123 configuration:self.mutableTemplateCopy];
3057
3058 // Find all the FlutterTextInputViews we created.
3059 NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
3060
3061 // The input view should not be hidden.
3062 XCTAssertEqual([inputFields count], 1u);
3063
3064 // Send show text input method call.
3065 [self setTextInputShow];
3066
3067 inputFields = self.installedInputViews;
3068
3069 XCTAssertEqual([inputFields count], 1u);
3070
3071 // Send hide text input method call.
3072 [self setTextInputHide];
3073
3074 inputFields = self.installedInputViews;
3075
3076 XCTAssertEqual([inputFields count], 1u);
3077
3078 // Send clear text client method call.
3079 [self setClientClear];
3080
3081 inputFields = self.installedInputViews;
3082
3083 // The input view should be hidden.
3084 XCTAssertEqual([inputFields count], 0u);
3085}
3086
3087- (void)testFlutterTextInputViewDirectFocusToBackingTextInput {
3088 FlutterTextInputViewSpy* inputView =
3089 [[FlutterTextInputViewSpy alloc] initWithOwner:textInputPlugin];
3090 UIView* container = [[UIView alloc] init];
3091 UIAccessibilityElement* backing =
3092 [[UIAccessibilityElement alloc] initWithAccessibilityContainer:container];
3093 inputView.backingTextInputAccessibilityObject = backing;
3094 // Simulate accessibility focus.
3095 inputView.isAccessibilityFocused = YES;
3096 [inputView accessibilityElementDidBecomeFocused];
3097
3098 XCTAssertEqual(inputView.receivedNotification, UIAccessibilityScreenChangedNotification);
3099 XCTAssertEqual(inputView.receivedNotificationTarget, backing);
3100}
3101
3102- (void)testFlutterTokenizerCanParseLines {
3103 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3104 id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
3105
3106 // The tokenizer returns zero range When text is empty.
3107 FlutterTextRange* range = [self getLineRangeFromTokenizer:tokenizer atIndex:0];
3108 XCTAssertEqual(range.range.location, 0u);
3109 XCTAssertEqual(range.range.length, 0u);
3110
3111 [inputView insertText:@"how are you\nI am fine, Thank you"];
3112
3113 range = [self getLineRangeFromTokenizer:tokenizer atIndex:0];
3114 XCTAssertEqual(range.range.location, 0u);
3115 XCTAssertEqual(range.range.length, 11u);
3116
3117 range = [self getLineRangeFromTokenizer:tokenizer atIndex:2];
3118 XCTAssertEqual(range.range.location, 0u);
3119 XCTAssertEqual(range.range.length, 11u);
3120
3121 range = [self getLineRangeFromTokenizer:tokenizer atIndex:11];
3122 XCTAssertEqual(range.range.location, 0u);
3123 XCTAssertEqual(range.range.length, 11u);
3124
3125 range = [self getLineRangeFromTokenizer:tokenizer atIndex:12];
3126 XCTAssertEqual(range.range.location, 12u);
3127 XCTAssertEqual(range.range.length, 20u);
3128
3129 range = [self getLineRangeFromTokenizer:tokenizer atIndex:15];
3130 XCTAssertEqual(range.range.location, 12u);
3131 XCTAssertEqual(range.range.length, 20u);
3132
3133 range = [self getLineRangeFromTokenizer:tokenizer atIndex:32];
3134 XCTAssertEqual(range.range.location, 12u);
3135 XCTAssertEqual(range.range.length, 20u);
3136}
3137
3138- (void)testFlutterTokenizerLineEnclosingEndOfDocumentInBackwardDirectionShouldNotReturnNil {
3139 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3140 [inputView insertText:@"0123456789\n012345"];
3141 id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
3142
3143 FlutterTextRange* range =
3144 (FlutterTextRange*)[tokenizer rangeEnclosingPosition:[inputView endOfDocument]
3145 withGranularity:UITextGranularityLine
3146 inDirection:UITextStorageDirectionBackward];
3147 XCTAssertEqual(range.range.location, 11u);
3148 XCTAssertEqual(range.range.length, 6u);
3149}
3150
3151- (void)testFlutterTokenizerLineEnclosingEndOfDocumentInForwardDirectionShouldReturnNilOnIOS17 {
3152 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3153 [inputView insertText:@"0123456789\n012345"];
3154 id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
3155
3156 FlutterTextRange* range =
3157 (FlutterTextRange*)[tokenizer rangeEnclosingPosition:[inputView endOfDocument]
3158 withGranularity:UITextGranularityLine
3159 inDirection:UITextStorageDirectionForward];
3160 if (@available(iOS 17.0, *)) {
3161 XCTAssertNil(range);
3162 } else {
3163 XCTAssertEqual(range.range.location, 11u);
3164 XCTAssertEqual(range.range.length, 6u);
3165 }
3166}
3167
3168- (void)testFlutterTokenizerLineEnclosingOutOfRangePositionShouldReturnNilOnIOS17 {
3169 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3170 [inputView insertText:@"0123456789\n012345"];
3171 id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
3172
3174 FlutterTextRange* range =
3175 (FlutterTextRange*)[tokenizer rangeEnclosingPosition:position
3176 withGranularity:UITextGranularityLine
3177 inDirection:UITextStorageDirectionForward];
3178 if (@available(iOS 17.0, *)) {
3179 XCTAssertNil(range);
3180 } else {
3181 XCTAssertEqual(range.range.location, 0u);
3182 XCTAssertEqual(range.range.length, 0u);
3183 }
3184}
3185
3186- (void)testFlutterTextInputPluginRetainsFlutterTextInputView {
3187 FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
3188 FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
3189 myInputPlugin.viewController = flutterViewController;
3190
3191 __weak UIView* activeView;
3192 @autoreleasepool {
3193 FlutterMethodCall* setClientCall = [FlutterMethodCall
3194 methodCallWithMethodName:@"TextInput.setClient"
3195 arguments:@[
3196 [NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy
3197 ]];
3198 [myInputPlugin handleMethodCall:setClientCall
3199 result:^(id _Nullable result){
3200 }];
3201 activeView = myInputPlugin.textInputView;
3202 FlutterMethodCall* hideCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
3203 arguments:@[]];
3204 [myInputPlugin handleMethodCall:hideCall
3205 result:^(id _Nullable result){
3206 }];
3207 XCTAssertNotNil(activeView);
3208 }
3209 // This assert proves the myInputPlugin.textInputView is not deallocated.
3210 XCTAssertNotNil(activeView);
3211}
3212
3213- (void)testFlutterTextInputPluginHostViewNilCrash {
3214 FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
3215 myInputPlugin.viewController = nil;
3216 XCTAssertThrows([myInputPlugin hostView], @"Throws exception if host view is nil");
3217}
3218
3219- (void)testFlutterTextInputPluginHostViewNotNil {
3220 FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
3221 FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
3222 [flutterEngine runWithEntrypoint:nil];
3223 flutterEngine.viewController = flutterViewController;
3224 XCTAssertNotNil(flutterEngine.textInputPlugin.viewController);
3225 XCTAssertNotNil([flutterEngine.textInputPlugin hostView]);
3226}
3227
3228- (void)testSetPlatformViewClient {
3229 FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
3230 FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
3231 myInputPlugin.viewController = flutterViewController;
3232
3233 FlutterMethodCall* setClientCall = [FlutterMethodCall
3234 methodCallWithMethodName:@"TextInput.setClient"
3235 arguments:@[ [NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy ]];
3236 [myInputPlugin handleMethodCall:setClientCall
3237 result:^(id _Nullable result){
3238 }];
3239 UIView* activeView = myInputPlugin.textInputView;
3240 XCTAssertNotNil(activeView.superview, @"activeView must be added to the view hierarchy.");
3241 FlutterMethodCall* setPlatformViewClientCall = [FlutterMethodCall
3242 methodCallWithMethodName:@"TextInput.setPlatformViewClient"
3243 arguments:@{@"platformViewId" : [NSNumber numberWithLong:456]}];
3244 [myInputPlugin handleMethodCall:setPlatformViewClientCall
3245 result:^(id _Nullable result){
3246 }];
3247 XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy.");
3248}
3249
3250- (void)testEditMenu_shouldSetupEditMenuDelegateCorrectly {
3251 if (@available(iOS 16.0, *)) {
3252 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3253 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3254 XCTAssertEqual(inputView.editMenuInteraction.delegate, inputView,
3255 @"editMenuInteraction setup delegate correctly");
3256 }
3257}
3258
3259- (void)testEditMenu_shouldNotPresentEditMenuIfNotFirstResponder {
3260 if (@available(iOS 16.0, *)) {
3261 FlutterTextInputPlugin* myInputPlugin =
3262 [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3263 BOOL shownEditMenu = [myInputPlugin showEditMenu:@{}];
3264 XCTAssertFalse(shownEditMenu, @"Should not show edit menu if not first responder.");
3265 }
3266}
3267
3268- (void)testEditMenu_shouldPresentEditMenuWithCorrectConfiguration {
3269 if (@available(iOS 16.0, *)) {
3270 FlutterTextInputPlugin* myInputPlugin =
3271 [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3272 FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3273 myInputPlugin.viewController = myViewController;
3274 [myViewController loadView];
3275 FlutterMethodCall* setClientCall =
3276 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3277 arguments:@[ @(123), self.mutableTemplateCopy ]];
3278 [myInputPlugin handleMethodCall:setClientCall
3279 result:^(id _Nullable result){
3280 }];
3281
3282 FlutterTextInputView* myInputView = myInputPlugin.activeView;
3283 FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3284
3285 OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3286
3287 XCTestExpectation* expectation = [[XCTestExpectation alloc]
3288 initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3289
3290 id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3291 OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3292 OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3293 .andDo(^(NSInvocation* invocation) {
3294 // arguments are released once invocation is released.
3295 [invocation retainArguments];
3296 UIEditMenuConfiguration* config;
3297 [invocation getArgument:&config atIndex:2];
3298 XCTAssertEqual(config.preferredArrowDirection, UIEditMenuArrowDirectionAutomatic,
3299 @"UIEditMenuConfiguration must use automatic arrow direction.");
3300 XCTAssert(CGPointEqualToPoint(config.sourcePoint, CGPointZero),
3301 @"UIEditMenuConfiguration must have the correct point.");
3302 [expectation fulfill];
3303 });
3304
3305 NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3306 @{@"x" : @(0), @"y" : @(0), @"width" : @(0), @"height" : @(0)};
3307
3308 BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}];
3309 XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3310 [self waitForExpectations:@[ expectation ] timeout:1.0];
3311 }
3312}
3313
3314- (void)testEditMenu_shouldPresentEditMenuWithCorectTargetRect {
3315 if (@available(iOS 16.0, *)) {
3316 FlutterTextInputPlugin* myInputPlugin =
3317 [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3318 FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3319 myInputPlugin.viewController = myViewController;
3320 [myViewController loadView];
3321
3322 FlutterMethodCall* setClientCall =
3323 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3324 arguments:@[ @(123), self.mutableTemplateCopy ]];
3325 [myInputPlugin handleMethodCall:setClientCall
3326 result:^(id _Nullable result){
3327 }];
3328
3329 FlutterTextInputView* myInputView = myInputPlugin.activeView;
3330
3331 FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3332 OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3333
3334 XCTestExpectation* expectation = [[XCTestExpectation alloc]
3335 initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3336
3337 id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3338 OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3339 OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3340 .andDo(^(NSInvocation* invocation) {
3341 [expectation fulfill];
3342 });
3343
3344 myInputView.frame = CGRectMake(10, 20, 30, 40);
3345 NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3346 @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3347
3348 BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}];
3349 XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3350 [self waitForExpectations:@[ expectation ] timeout:1.0];
3351
3352 CGRect targetRect =
3353 [myInputView editMenuInteraction:mockInteraction
3354 targetRectForConfiguration:OCMClassMock([UIEditMenuConfiguration class])];
3355 // the encoded target rect is in global coordinate space.
3356 XCTAssert(CGRectEqualToRect(targetRect, CGRectMake(90, 180, 300, 400)),
3357 @"targetRectForConfiguration must return the correct target rect.");
3358 }
3359}
3360
3361- (void)testEditMenu_shouldPresentEditMenuWithSuggestedItemsByDefaultIfNoFrameworkData {
3362 if (@available(iOS 16.0, *)) {
3363 FlutterTextInputPlugin* myInputPlugin =
3364 [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3365 FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3366 myInputPlugin.viewController = myViewController;
3367 [myViewController loadView];
3368
3369 FlutterMethodCall* setClientCall =
3370 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3371 arguments:@[ @(123), self.mutableTemplateCopy ]];
3372 [myInputPlugin handleMethodCall:setClientCall
3373 result:^(id _Nullable result){
3374 }];
3375
3376 FlutterTextInputView* myInputView = myInputPlugin.activeView;
3377
3378 FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3379 OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3380
3381 XCTestExpectation* expectation = [[XCTestExpectation alloc]
3382 initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3383
3384 id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3385 OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3386 OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3387 .andDo(^(NSInvocation* invocation) {
3388 [expectation fulfill];
3389 });
3390
3391 myInputView.frame = CGRectMake(10, 20, 30, 40);
3392 NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3393 @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3394 // No items provided from framework. Show the suggested items by default.
3395 BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}];
3396 XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3397 [self waitForExpectations:@[ expectation ] timeout:1.0];
3398
3399 UICommand* copyItem = [UICommand commandWithTitle:@"Copy"
3400 image:nil
3401 action:@selector(copy:)
3402 propertyList:nil];
3403 UICommand* pasteItem = [UICommand commandWithTitle:@"Paste"
3404 image:nil
3405 action:@selector(paste:)
3406 propertyList:nil];
3407 NSArray<UICommand*>* suggestedActions = @[ copyItem, pasteItem ];
3408
3409 UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
3410 menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
3411 suggestedActions:suggestedActions];
3412 XCTAssertEqualObjects(menu.children, suggestedActions,
3413 @"Must show suggested items by default.");
3414 }
3415}
3416
3417- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsAndCorrectOrderingForBasicEditingActions {
3418 if (@available(iOS 16.0, *)) {
3419 FlutterTextInputPlugin* myInputPlugin =
3420 [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3421 FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3422 myInputPlugin.viewController = myViewController;
3423 [myViewController loadView];
3424
3425 FlutterMethodCall* setClientCall =
3426 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3427 arguments:@[ @(123), self.mutableTemplateCopy ]];
3428 [myInputPlugin handleMethodCall:setClientCall
3429 result:^(id _Nullable result){
3430 }];
3431
3432 FlutterTextInputView* myInputView = myInputPlugin.activeView;
3433
3434 FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3435 OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3436
3437 XCTestExpectation* expectation = [[XCTestExpectation alloc]
3438 initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3439
3440 id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3441 OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3442 OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3443 .andDo(^(NSInvocation* invocation) {
3444 [expectation fulfill];
3445 });
3446
3447 myInputView.frame = CGRectMake(10, 20, 30, 40);
3448 NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3449 @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3450
3451 NSArray<NSDictionary<NSString*, id>*>* encodedItems =
3452 @[ @{@"type" : @"paste"}, @{@"type" : @"copy"} ];
3453
3454 BOOL shownEditMenu =
3455 [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
3456 XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3457 [self waitForExpectations:@[ expectation ] timeout:1.0];
3458
3459 UICommand* copyItem = [UICommand commandWithTitle:@"Copy"
3460 image:nil
3461 action:@selector(copy:)
3462 propertyList:nil];
3463 UICommand* pasteItem = [UICommand commandWithTitle:@"Paste"
3464 image:nil
3465 action:@selector(paste:)
3466 propertyList:nil];
3467 NSArray<UICommand*>* suggestedActions = @[ copyItem, pasteItem ];
3468
3469 UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
3470 menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
3471 suggestedActions:suggestedActions];
3472 // The item ordering should follow the encoded data sent from the framework.
3473 NSArray<UICommand*>* expectedChildren = @[ pasteItem, copyItem ];
3474 XCTAssertEqualObjects(menu.children, expectedChildren);
3475 }
3476}
3477
3478- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsUnderNestedSubtreeForBasicEditingActions {
3479 if (@available(iOS 16.0, *)) {
3480 FlutterTextInputPlugin* myInputPlugin =
3481 [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3482 FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3483 myInputPlugin.viewController = myViewController;
3484 [myViewController loadView];
3485
3486 FlutterMethodCall* setClientCall =
3487 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3488 arguments:@[ @(123), self.mutableTemplateCopy ]];
3489 [myInputPlugin handleMethodCall:setClientCall
3490 result:^(id _Nullable result){
3491 }];
3492
3493 FlutterTextInputView* myInputView = myInputPlugin.activeView;
3494
3495 FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3496 OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3497
3498 XCTestExpectation* expectation = [[XCTestExpectation alloc]
3499 initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3500
3501 id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3502 OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3503 OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3504 .andDo(^(NSInvocation* invocation) {
3505 [expectation fulfill];
3506 });
3507
3508 myInputView.frame = CGRectMake(10, 20, 30, 40);
3509 NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3510 @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3511
3512 NSArray<NSDictionary<NSString*, id>*>* encodedItems =
3513 @[ @{@"type" : @"cut"}, @{@"type" : @"paste"}, @{@"type" : @"copy"} ];
3514
3515 BOOL shownEditMenu =
3516 [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
3517 XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3518 [self waitForExpectations:@[ expectation ] timeout:1.0];
3519
3520 UICommand* copyItem = [UICommand commandWithTitle:@"Copy"
3521 image:nil
3522 action:@selector(copy:)
3523 propertyList:nil];
3524 UICommand* cutItem = [UICommand commandWithTitle:@"Cut"
3525 image:nil
3526 action:@selector(cut:)
3527 propertyList:nil];
3528 UICommand* pasteItem = [UICommand commandWithTitle:@"Paste"
3529 image:nil
3530 action:@selector(paste:)
3531 propertyList:nil];
3532 /*
3533 A more complex menu hierarchy for DFS:
3534
3535 menu
3536 / | \
3537 copy menu menu
3538 | \
3539 paste menu
3540 |
3541 cut
3542 */
3543 NSArray<UIMenuElement*>* suggestedActions = @[
3544 copyItem, [UIMenu menuWithChildren:@[ pasteItem ]],
3545 [UIMenu menuWithChildren:@[ [UIMenu menuWithChildren:@[ cutItem ]] ]]
3546 ];
3547
3548 UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
3549 menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
3550 suggestedActions:suggestedActions];
3551 // The item ordering should follow the encoded data sent from the framework.
3552 NSArray<UICommand*>* expectedActions = @[ cutItem, pasteItem, copyItem ];
3553 XCTAssertEqualObjects(menu.children, expectedActions);
3554 }
3555}
3556
3557- (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForMoreAdditionalItems {
3558 if (@available(iOS 16.0, *)) {
3559 FlutterTextInputPlugin* myInputPlugin =
3560 [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3561 FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3562 myInputPlugin.viewController = myViewController;
3563 [myViewController loadView];
3564
3565 FlutterMethodCall* setClientCall =
3566 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3567 arguments:@[ @(123), self.mutableTemplateCopy ]];
3568 [myInputPlugin handleMethodCall:setClientCall
3569 result:^(id _Nullable result){
3570 }];
3571
3572 FlutterTextInputView* myInputView = myInputPlugin.activeView;
3573
3574 FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3575 OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3576
3577 XCTestExpectation* expectation = [[XCTestExpectation alloc]
3578 initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3579
3580 id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3581 OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3582 OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3583 .andDo(^(NSInvocation* invocation) {
3584 [expectation fulfill];
3585 });
3586
3587 myInputView.frame = CGRectMake(10, 20, 30, 40);
3588 NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3589 @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3590
3591 NSArray<NSDictionary<NSString*, id>*>* encodedItems = @[
3592 @{@"type" : @"searchWeb", @"title" : @"Search Web"},
3593 @{@"type" : @"lookUp", @"title" : @"Look Up"}, @{@"type" : @"share", @"title" : @"Share"}
3594 ];
3595
3596 BOOL shownEditMenu =
3597 [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
3598 XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3599 [self waitForExpectations:@[ expectation ] timeout:1.0];
3600
3601 NSArray<UICommand*>* suggestedActions = @[
3602 [UICommand commandWithTitle:@"copy" image:nil action:@selector(copy:) propertyList:nil],
3603 ];
3604
3605 UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
3606 menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
3607 suggestedActions:suggestedActions];
3608 XCTAssert(menu.children.count == 3, @"There must be 3 menu items");
3609
3610 XCTAssert(((UICommand*)menu.children[0]).action == @selector(handleSearchWebAction),
3611 @"Must create search web item in the tree.");
3612 XCTAssert(((UICommand*)menu.children[1]).action == @selector(handleLookUpAction),
3613 @"Must create look up item in the tree.");
3614 XCTAssert(((UICommand*)menu.children[2]).action == @selector(handleShareAction),
3615 @"Must create share item in the tree.");
3616 }
3617}
3618
3619- (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder {
3620 if (@available(iOS 17.0, *)) {
3621 XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3622 "https://github.com/flutter/flutter/issues/183473");
3623 }
3624 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3625 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3626
3627 [inputView setTextInputClient:123];
3628 [inputView reloadInputViews];
3629 [inputView becomeFirstResponder];
3630 XCTAssert(inputView.isFirstResponder);
3631
3632 CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3633 [NSNotificationCenter.defaultCenter
3634 postNotificationName:UIKeyboardWillShowNotification
3635 object:nil
3636 userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3637 FlutterMethodCall* onPointerMoveCall =
3638 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3639 arguments:@{@"pointerY" : @(500)}];
3640 [textInputPlugin handleMethodCall:onPointerMoveCall
3641 result:^(id _Nullable result){
3642 }];
3643 XCTAssertFalse(inputView.isFirstResponder);
3644 textInputPlugin.cachedFirstResponder = nil;
3645}
3646
3647- (void)testInteractiveKeyboardAfterUserScrollToTopOfKeyboardWillTakeScreenshot {
3648 if (@available(iOS 17.0, *)) {
3649 XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3650 "https://github.com/flutter/flutter/issues/183473");
3651 }
3652 NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3653 XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3654 UIScene* scene = scenes.anyObject;
3655 XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3656 UIWindowScene* windowScene = (UIWindowScene*)scene;
3657 XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3658 UIWindow* window = windowScene.windows[0];
3659 [window addSubview:viewController.view];
3660
3661 [viewController loadView];
3662
3663 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3664 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3665
3666 [inputView setTextInputClient:123];
3667 [inputView reloadInputViews];
3668 [inputView becomeFirstResponder];
3669
3670 if (textInputPlugin.keyboardView.superview != nil) {
3671 for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
3672 [subView removeFromSuperview];
3673 }
3674 }
3675 XCTAssert(textInputPlugin.keyboardView.superview == nil);
3676 CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3677 [NSNotificationCenter.defaultCenter
3678 postNotificationName:UIKeyboardWillShowNotification
3679 object:nil
3680 userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3681 FlutterMethodCall* onPointerMoveCall =
3682 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3683 arguments:@{@"pointerY" : @(510)}];
3684 [textInputPlugin handleMethodCall:onPointerMoveCall
3685 result:^(id _Nullable result){
3686 }];
3687 XCTAssertFalse(textInputPlugin.keyboardView.superview == nil);
3688 for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
3689 [subView removeFromSuperview];
3690 }
3691 textInputPlugin.cachedFirstResponder = nil;
3692}
3693
3694- (void)testInteractiveKeyboardScreenshotWillBeMovedDownAfterUserScroll {
3695 if (@available(iOS 17.0, *)) {
3696 XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3697 "https://github.com/flutter/flutter/issues/183473");
3698 }
3699 NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3700 XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3701 UIScene* scene = scenes.anyObject;
3702 XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3703 UIWindowScene* windowScene = (UIWindowScene*)scene;
3704 XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3705 UIWindow* window = windowScene.windows[0];
3706 [window addSubview:viewController.view];
3707
3708 [viewController loadView];
3709
3710 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3711 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3712
3713 [inputView setTextInputClient:123];
3714 [inputView reloadInputViews];
3715 [inputView becomeFirstResponder];
3716
3717 CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3718 [NSNotificationCenter.defaultCenter
3719 postNotificationName:UIKeyboardWillShowNotification
3720 object:nil
3721 userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3722 FlutterMethodCall* onPointerMoveCall =
3723 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3724 arguments:@{@"pointerY" : @(510)}];
3725 [textInputPlugin handleMethodCall:onPointerMoveCall
3726 result:^(id _Nullable result){
3727 }];
3728 XCTAssert(textInputPlugin.keyboardView.superview != nil);
3729
3730 XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
3731
3732 FlutterMethodCall* onPointerMoveCallMove =
3733 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3734 arguments:@{@"pointerY" : @(600)}];
3735 [textInputPlugin handleMethodCall:onPointerMoveCallMove
3736 result:^(id _Nullable result){
3737 }];
3738 XCTAssert(textInputPlugin.keyboardView.superview != nil);
3739
3740 XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0);
3741
3742 for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
3743 [subView removeFromSuperview];
3744 }
3745 textInputPlugin.cachedFirstResponder = nil;
3746}
3747
3748- (void)testInteractiveKeyboardScreenshotWillBeMovedToOrginalPositionAfterUserScroll {
3749 if (@available(iOS 17.0, *)) {
3750 XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3751 "https://github.com/flutter/flutter/issues/183473");
3752 }
3753 NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3754 XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3755 UIScene* scene = scenes.anyObject;
3756 XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3757 UIWindowScene* windowScene = (UIWindowScene*)scene;
3758 XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3759 UIWindow* window = windowScene.windows[0];
3760 [window addSubview:viewController.view];
3761
3762 [viewController loadView];
3763
3764 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3765 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3766
3767 [inputView setTextInputClient:123];
3768 [inputView reloadInputViews];
3769 [inputView becomeFirstResponder];
3770
3771 CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3772 [NSNotificationCenter.defaultCenter
3773 postNotificationName:UIKeyboardWillShowNotification
3774 object:nil
3775 userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3776 FlutterMethodCall* onPointerMoveCall =
3777 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3778 arguments:@{@"pointerY" : @(500)}];
3779 [textInputPlugin handleMethodCall:onPointerMoveCall
3780 result:^(id _Nullable result){
3781 }];
3782 XCTAssert(textInputPlugin.keyboardView.superview != nil);
3783 XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
3784
3785 FlutterMethodCall* onPointerMoveCallMove =
3786 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3787 arguments:@{@"pointerY" : @(600)}];
3788 [textInputPlugin handleMethodCall:onPointerMoveCallMove
3789 result:^(id _Nullable result){
3790 }];
3791 XCTAssert(textInputPlugin.keyboardView.superview != nil);
3792 XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0);
3793
3794 FlutterMethodCall* onPointerMoveCallBackUp =
3795 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3796 arguments:@{@"pointerY" : @(10)}];
3797 [textInputPlugin handleMethodCall:onPointerMoveCallBackUp
3798 result:^(id _Nullable result){
3799 }];
3800 XCTAssert(textInputPlugin.keyboardView.superview != nil);
3801 XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
3802 for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
3803 [subView removeFromSuperview];
3804 }
3805 textInputPlugin.cachedFirstResponder = nil;
3806}
3807
3808- (void)testInteractiveKeyboardFindFirstResponderRecursive {
3809 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3810 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3811 [inputView setTextInputClient:123];
3812 [inputView reloadInputViews];
3813 [inputView becomeFirstResponder];
3814
3815 UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
3816 XCTAssertEqualObjects(inputView, firstResponder);
3817 textInputPlugin.cachedFirstResponder = nil;
3818}
3819
3820- (void)testInteractiveKeyboardFindFirstResponderRecursiveInMultipleSubviews {
3821 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3822 FlutterTextInputView* subInputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3823 FlutterTextInputView* otherSubInputView =
3824 [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3825 FlutterTextInputView* subFirstResponderInputView =
3826 [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3827 [subInputView addSubview:subFirstResponderInputView];
3828 [inputView addSubview:subInputView];
3829 [inputView addSubview:otherSubInputView];
3830 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3831 [inputView setTextInputClient:123];
3832 [inputView reloadInputViews];
3833 [subInputView setTextInputClient:123];
3834 [subInputView reloadInputViews];
3835 [otherSubInputView setTextInputClient:123];
3836 [otherSubInputView reloadInputViews];
3837 [subFirstResponderInputView setTextInputClient:123];
3838 [subFirstResponderInputView reloadInputViews];
3839 [subFirstResponderInputView becomeFirstResponder];
3840
3841 UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
3842 XCTAssertEqualObjects(subFirstResponderInputView, firstResponder);
3843 textInputPlugin.cachedFirstResponder = nil;
3844}
3845
3846- (void)testInteractiveKeyboardFindFirstResponderIsNilRecursive {
3847 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3848 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3849 [inputView setTextInputClient:123];
3850 [inputView reloadInputViews];
3851
3852 UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
3853 XCTAssertNil(firstResponder);
3854 textInputPlugin.cachedFirstResponder = nil;
3855}
3856
3857- (void)testInteractiveKeyboardDidResignFirstResponderDelegateisCalledAfterDismissedKeyboard {
3858 if (@available(iOS 17.0, *)) {
3859 XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3860 "https://github.com/flutter/flutter/issues/183473");
3861 }
3862 NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3863 XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3864 UIScene* scene = scenes.anyObject;
3865 XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3866 UIWindowScene* windowScene = (UIWindowScene*)scene;
3867 XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3868 UIWindow* window = windowScene.windows[0];
3869 [window addSubview:viewController.view];
3870
3871 [viewController loadView];
3872
3873 XCTestExpectation* expectation = [[XCTestExpectation alloc]
3874 initWithDescription:
3875 @"didResignFirstResponder is called after screenshot keyboard dismissed."];
3876 OCMStub([engine flutterTextInputView:[OCMArg any] didResignFirstResponderWithTextInputClient:0])
3877 .andDo(^(NSInvocation* invocation) {
3878 [expectation fulfill];
3879 });
3880 CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3881 [NSNotificationCenter.defaultCenter
3882 postNotificationName:UIKeyboardWillShowNotification
3883 object:nil
3884 userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3885 FlutterMethodCall* initialMoveCall =
3886 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3887 arguments:@{@"pointerY" : @(500)}];
3888 [textInputPlugin handleMethodCall:initialMoveCall
3889 result:^(id _Nullable result){
3890 }];
3891 FlutterMethodCall* subsequentMoveCall =
3892 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3893 arguments:@{@"pointerY" : @(1000)}];
3894 [textInputPlugin handleMethodCall:subsequentMoveCall
3895 result:^(id _Nullable result){
3896 }];
3897
3898 FlutterMethodCall* pointerUpCall =
3899 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
3900 arguments:@{@"pointerY" : @(1000)}];
3901 [textInputPlugin handleMethodCall:pointerUpCall
3902 result:^(id _Nullable result){
3903 }];
3904
3905 [self waitForExpectations:@[ expectation ] timeout:2.0];
3906 textInputPlugin.cachedFirstResponder = nil;
3907}
3908
3909- (void)testInteractiveKeyboardScreenshotDismissedAfterPointerLiftedAboveMiddleYOfKeyboard {
3910 if (@available(iOS 17.0, *)) {
3911 XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3912 "https://github.com/flutter/flutter/issues/183473");
3913 }
3914 NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3915 XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3916 UIScene* scene = scenes.anyObject;
3917 XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3918 UIWindowScene* windowScene = (UIWindowScene*)scene;
3919 XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3920 UIWindow* window = windowScene.windows[0];
3921 [window addSubview:viewController.view];
3922
3923 [viewController loadView];
3924
3925 CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3926 [NSNotificationCenter.defaultCenter
3927 postNotificationName:UIKeyboardWillShowNotification
3928 object:nil
3929 userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3930 FlutterMethodCall* initialMoveCall =
3931 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3932 arguments:@{@"pointerY" : @(500)}];
3933 [textInputPlugin handleMethodCall:initialMoveCall
3934 result:^(id _Nullable result){
3935 }];
3936 FlutterMethodCall* subsequentMoveCall =
3937 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3938 arguments:@{@"pointerY" : @(1000)}];
3939 [textInputPlugin handleMethodCall:subsequentMoveCall
3940 result:^(id _Nullable result){
3941 }];
3942
3943 FlutterMethodCall* subsequentMoveBackUpCall =
3944 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3945 arguments:@{@"pointerY" : @(0)}];
3946 [textInputPlugin handleMethodCall:subsequentMoveBackUpCall
3947 result:^(id _Nullable result){
3948 }];
3949
3950 FlutterMethodCall* pointerUpCall =
3951 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
3952 arguments:@{@"pointerY" : @(0)}];
3953 [textInputPlugin handleMethodCall:pointerUpCall
3954 result:^(id _Nullable result){
3955 }];
3956 NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
3957 return textInputPlugin.keyboardViewContainer.subviews.count == 0;
3958 }];
3959 XCTNSPredicateExpectation* expectation =
3960 [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
3961 [self waitForExpectations:@[ expectation ] timeout:10.0];
3962 textInputPlugin.cachedFirstResponder = nil;
3963}
3964
3965- (void)testInteractiveKeyboardKeyboardReappearsAfterPointerLiftedAboveMiddleYOfKeyboard {
3966 if (@available(iOS 17.0, *)) {
3967 XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3968 "https://github.com/flutter/flutter/issues/183473");
3969 }
3970 NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3971 XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3972 UIScene* scene = scenes.anyObject;
3973 XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3974 UIWindowScene* windowScene = (UIWindowScene*)scene;
3975 XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3976 UIWindow* window = windowScene.windows[0];
3977 [window addSubview:viewController.view];
3978
3979 [viewController loadView];
3980
3981 FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3982 [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3983
3984 [inputView setTextInputClient:123];
3985 [inputView reloadInputViews];
3986 [inputView becomeFirstResponder];
3987
3988 CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3989 [NSNotificationCenter.defaultCenter
3990 postNotificationName:UIKeyboardWillShowNotification
3991 object:nil
3992 userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3993 FlutterMethodCall* initialMoveCall =
3994 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3995 arguments:@{@"pointerY" : @(500)}];
3996 [textInputPlugin handleMethodCall:initialMoveCall
3997 result:^(id _Nullable result){
3998 }];
3999 FlutterMethodCall* subsequentMoveCall =
4000 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4001 arguments:@{@"pointerY" : @(1000)}];
4002 [textInputPlugin handleMethodCall:subsequentMoveCall
4003 result:^(id _Nullable result){
4004 }];
4005
4006 FlutterMethodCall* subsequentMoveBackUpCall =
4007 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4008 arguments:@{@"pointerY" : @(0)}];
4009 [textInputPlugin handleMethodCall:subsequentMoveBackUpCall
4010 result:^(id _Nullable result){
4011 }];
4012
4013 FlutterMethodCall* pointerUpCall =
4014 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
4015 arguments:@{@"pointerY" : @(0)}];
4016 [textInputPlugin handleMethodCall:pointerUpCall
4017 result:^(id _Nullable result){
4018 }];
4019 NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
4020 return textInputPlugin.cachedFirstResponder.isFirstResponder;
4021 }];
4022 XCTNSPredicateExpectation* expectation =
4023 [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
4024 [self waitForExpectations:@[ expectation ] timeout:10.0];
4025 textInputPlugin.cachedFirstResponder = nil;
4026}
4027
4028- (void)testInteractiveKeyboardKeyboardAnimatesToOriginalPositionalOnPointerUp {
4029 if (@available(iOS 17.0, *)) {
4030 XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
4031 "https://github.com/flutter/flutter/issues/183473");
4032 }
4033 NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
4034 XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
4035 UIScene* scene = scenes.anyObject;
4036 XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
4037 UIWindowScene* windowScene = (UIWindowScene*)scene;
4038 XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
4039 UIWindow* window = windowScene.windows[0];
4040 [window addSubview:viewController.view];
4041
4042 [viewController loadView];
4043
4044 XCTestExpectation* expectation =
4045 [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."];
4046 CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
4047 [NSNotificationCenter.defaultCenter
4048 postNotificationName:UIKeyboardWillShowNotification
4049 object:nil
4050 userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
4051 FlutterMethodCall* initialMoveCall =
4052 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4053 arguments:@{@"pointerY" : @(500)}];
4054 [textInputPlugin handleMethodCall:initialMoveCall
4055 result:^(id _Nullable result){
4056 }];
4057 FlutterMethodCall* subsequentMoveCall =
4058 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4059 arguments:@{@"pointerY" : @(1000)}];
4060 [textInputPlugin handleMethodCall:subsequentMoveCall
4061 result:^(id _Nullable result){
4062 }];
4063 FlutterMethodCall* upwardVelocityMoveCall =
4064 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4065 arguments:@{@"pointerY" : @(500)}];
4066 [textInputPlugin handleMethodCall:upwardVelocityMoveCall
4067 result:^(id _Nullable result){
4068 }];
4069
4070 FlutterMethodCall* pointerUpCall =
4071 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
4072 arguments:@{@"pointerY" : @(0)}];
4073 [textInputPlugin
4074 handleMethodCall:pointerUpCall
4075 result:^(id _Nullable result) {
4076 XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y,
4077 viewController.flutterScreenIfViewLoaded.bounds.size.height -
4078 keyboardFrame.origin.y);
4079 [expectation fulfill];
4080 }];
4081 textInputPlugin.cachedFirstResponder = nil;
4082}
4083
4084- (void)testInteractiveKeyboardKeyboardAnimatesToDismissalPositionalOnPointerUp {
4085 if (@available(iOS 17.0, *)) {
4086 XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
4087 "https://github.com/flutter/flutter/issues/183473");
4088 }
4089 NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
4090 XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
4091 UIScene* scene = scenes.anyObject;
4092 XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
4093 UIWindowScene* windowScene = (UIWindowScene*)scene;
4094 XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
4095 UIWindow* window = windowScene.windows[0];
4096 [window addSubview:viewController.view];
4097
4098 [viewController loadView];
4099
4100 XCTestExpectation* expectation =
4101 [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."];
4102 CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
4103 [NSNotificationCenter.defaultCenter
4104 postNotificationName:UIKeyboardWillShowNotification
4105 object:nil
4106 userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
4107 FlutterMethodCall* initialMoveCall =
4108 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4109 arguments:@{@"pointerY" : @(500)}];
4110 [textInputPlugin handleMethodCall:initialMoveCall
4111 result:^(id _Nullable result){
4112 }];
4113 FlutterMethodCall* subsequentMoveCall =
4114 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4115 arguments:@{@"pointerY" : @(1000)}];
4116 [textInputPlugin handleMethodCall:subsequentMoveCall
4117 result:^(id _Nullable result){
4118 }];
4119
4120 FlutterMethodCall* pointerUpCall =
4121 [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
4122 arguments:@{@"pointerY" : @(1000)}];
4123 [textInputPlugin
4124 handleMethodCall:pointerUpCall
4125 result:^(id _Nullable result) {
4126 XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y,
4127 viewController.flutterScreenIfViewLoaded.bounds.size.height);
4128 [expectation fulfill];
4129 }];
4130 textInputPlugin.cachedFirstResponder = nil;
4131}
4132- (void)testInteractiveKeyboardShowKeyboardAndRemoveScreenshotAnimationIsNotImmediatelyEnable {
4133 [UIView setAnimationsEnabled:YES];
4134 [textInputPlugin showKeyboardAndRemoveScreenshot];
4135 XCTAssertFalse(
4136 UIView.areAnimationsEnabled,
4137 @"The animation should still be disabled following showKeyboardAndRemoveScreenshot");
4138}
4139
4140- (void)testInteractiveKeyboardShowKeyboardAndRemoveScreenshotAnimationIsReenabledAfterDelay {
4141 [UIView setAnimationsEnabled:YES];
4142 [textInputPlugin showKeyboardAndRemoveScreenshot];
4143
4144 NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
4145 // This will be enabled after a delay
4146 return UIView.areAnimationsEnabled;
4147 }];
4148 XCTNSPredicateExpectation* expectation =
4149 [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
4150 [self waitForExpectations:@[ expectation ] timeout:10.0];
4151}
4152
4153- (void)testEditMenu_shouldCreateCustomMenuItemWithCorrectProperties {
4154 if (@available(iOS 16.0, *)) {
4155 FlutterTextInputPlugin* myInputPlugin =
4156 [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
4157 FlutterViewController* myViewController = [[FlutterViewController alloc] init];
4158 myInputPlugin.viewController = myViewController;
4159 [myViewController loadView];
4160
4161 FlutterMethodCall* setClientCall =
4162 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
4163 arguments:@[ @(123), self.mutableTemplateCopy ]];
4164 [myInputPlugin handleMethodCall:setClientCall
4165 result:^(id _Nullable result){
4166 }];
4167
4168 FlutterTextInputView* myInputView = myInputPlugin.activeView;
4169 FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
4170 OCMStub([mockInputView isFirstResponder]).andReturn(YES);
4171
4172 id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
4173 OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
4174
4175 NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
4176 @{@"x" : @(0), @"y" : @(0), @"width" : @(100), @"height" : @(50)};
4177
4178 NSArray<NSDictionary*>* encodedItems = @[
4179 @{@"type" : @"custom", @"id" : @"custom-action-1", @"title" : @"Custom Action 1"},
4180 @{@"type" : @"custom", @"id" : @"custom-action-2", @"title" : @"Custom Action 2"},
4181 ];
4182
4183 BOOL shownEditMenu =
4184 [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
4185 XCTAssertTrue(shownEditMenu, @"Should show edit menu");
4186
4187 UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
4188 menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
4189 suggestedActions:@[]];
4190
4191 XCTAssertEqual(menu.children.count, 2UL, @"Should create 2 custom menu items");
4192 UIAction* firstAction = (UIAction*)menu.children[0];
4193 UIAction* secondAction = (UIAction*)menu.children[1];
4194 XCTAssertEqualObjects(firstAction.title, @"Custom Action 1",
4195 @"First action title should match");
4196 XCTAssertEqualObjects(secondAction.title, @"Custom Action 2",
4197 @"Second action title should match");
4198 }
4199}
4200
4201- (void)testEditMenu_customActionShouldTriggerDelegateCallback {
4202 if (@available(iOS 16.0, *)) {
4203 id mockEngine = OCMClassMock([FlutterEngine class]);
4204 id mockPlatformChannel = OCMClassMock([FlutterMethodChannel class]);
4205 OCMStub([mockEngine platformChannel]).andReturn(mockPlatformChannel);
4206
4207 OCMStub([mockEngine flutterTextInputView:[OCMArg any]
4208 performContextMenuCustomActionWithActionID:@"test-callback-id"
4209 textInputClient:123])
4210 .andDo((^(NSInvocation* invocation) {
4211 [mockPlatformChannel invokeMethod:@"ContextMenu.onPerformCustomAction"
4212 arguments:@[ @(123), @"test-callback-id" ]];
4213 }));
4214
4215 FlutterTextInputPlugin* myInputPlugin =
4216 [[FlutterTextInputPlugin alloc] initWithDelegate:mockEngine];
4217 FlutterViewController* myViewController = [[FlutterViewController alloc] init];
4218 myInputPlugin.viewController = myViewController;
4219 [myViewController loadView];
4220
4221 FlutterMethodCall* setClientCall =
4222 [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
4223 arguments:@[ @(123), self.mutableTemplateCopy ]];
4224 [myInputPlugin handleMethodCall:setClientCall
4225 result:^(id _Nullable result){
4226 }];
4227
4228 FlutterTextInputView* myInputView = myInputPlugin.activeView;
4229 FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
4230 OCMStub([mockInputView isFirstResponder]).andReturn(YES);
4231 XCTestExpectation* expectation = [[XCTestExpectation alloc]
4232 initWithDescription:@"Custom action delegate callback should be called"];
4233 OCMStub(([mockPlatformChannel invokeMethod:@"ContextMenu.onPerformCustomAction"
4234 arguments:@[ @(123), @"test-callback-id" ]]))
4235 .andDo(^(NSInvocation* invocation) {
4236 [expectation fulfill];
4237 });
4238 id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
4239 OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
4240
4241 NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
4242 @{@"x" : @(0), @"y" : @(0), @"width" : @(100), @"height" : @(50)};
4243
4244 NSArray<NSDictionary*>* encodedItems = @[
4245 @{@"type" : @"custom", @"id" : @"test-callback-id", @"title" : @"Test Action"},
4246 ];
4247
4248 BOOL shownEditMenu =
4249 [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
4250 XCTAssertTrue(shownEditMenu, @"Should show edit menu");
4251
4252 UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
4253 menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
4254 suggestedActions:@[]];
4255
4256 XCTAssertEqual(menu.children.count, 1UL, @"Should have 1 custom menu item");
4257 UIAction* customAction = (UIAction*)menu.children[0];
4258 XCTAssertEqualObjects(customAction.title, @"Test Action", @"Action title should match");
4259
4260 [myInputView.textInputDelegate flutterTextInputView:myInputView
4261 performContextMenuCustomActionWithActionID:@"test-callback-id"
4262 textInputClient:123];
4263
4264 [self waitForExpectations:@[ expectation ] timeout:1.0];
4265 OCMVerifyAll(mockPlatformChannel);
4266 }
4267}
4268
4269@end
std::unique_ptr< flutter::PlatformViewIOS > platform_view
AssetResolverType
Identifies the type of AssetResolver an instance is.
A Mapping like NonOwnedMapping, but uses Free as its release proc.
Definition mapping.h:144
@ kSoftware
Definition embedder.h:81
Settings settings_
GLFWwindow * window
Definition main.cc:60
FlutterEngine engine
Definition main.cc:84
VkSurfaceKHR surface
Definition main.cc:65
const char * message
G_BEGIN_DECLS G_MODULE_EXPORT FlValue * args
uint32_t * target
G_BEGIN_DECLS FlutterViewId view_id
FlutterDesktopBinaryReply callback
BOOL runWithEntrypoint:(nullable NSString *entrypoint)
void setBinaryMessenger:(FlutterBinaryMessengerRelay *binaryMessenger)
void flutterTextInputView:performAction:withClient:(FlutterTextInputView *textInputView,[performAction] FlutterTextInputAction action,[withClient] int client)
BOOL runWithEntrypoint:initialRoute:(nullable NSString *entrypoint,[initialRoute] nullable NSString *initialRoute)
FlutterViewController * viewController
instancetype methodCallWithMethodName:arguments:(NSString *method,[arguments] id _Nullable arguments)
UIView< UITextInput > * textInputView()
UIIndirectScribbleInteractionDelegate UIViewController * viewController
BOOL showEditMenu:(ios(16.0) API_AVAILABLE)
void handleMethodCall:result:(FlutterMethodCall *call,[result] FlutterResult result)
UIAccessibilityNotifications receivedNotification
instancetype positionWithIndex:(NSUInteger index)
UITextStorageDirection affinity
instancetype rangeWithNSRange:(NSRange range)
instancetype selectionRectWithRect:position:(CGRect rect,[position] NSUInteger position)
instancetype selectionRectWithRect:position:writingDirection:(CGRect rect,[position] NSUInteger position,[writingDirection] NSWritingDirection writingDirection)
NSArray< FlutterTextSelectionRect * > * selectionRects
BOOL isScribbleAvailable
UITextRange * markedTextRange
API_AVAILABLE(ios(13.0)) @interface FlutterTextPlaceholder UITextRange * selectedTextRange
UITextSmartQuotesType smartQuotesType API_AVAILABLE(ios(11.0))
CGRect caretRectForPosition
const CGRect kInvalidFirstRect
NSDictionary * _passwordTemplate
FlutterViewController * viewController
FlutterTextInputPlugin * textInputPlugin
FlTexture * texture
std::function< void()> closure
Definition closure.h:14
Definition ref_ptr.h:261
impeller::ShaderType type
const size_t end
int64_t texture_id
const uintptr_t id
int BOOL