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