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