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