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 - (void)testReplaceTestLocalAdjustSelectionAndMarkedTextRange {
790  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
791  [inputView setMarkedText:@"test text" selectedRange:NSMakeRange(0, 5)];
792  NSRange selectedTextRange = ((FlutterTextRange*)inputView.selectedTextRange).range;
793  const NSRange markedTextRange = ((FlutterTextRange*)inputView.markedTextRange).range;
794  XCTAssertEqual(selectedTextRange.location, 0ul);
795  XCTAssertEqual(selectedTextRange.length, 5ul);
796  XCTAssertEqual(markedTextRange.location, 0ul);
797  XCTAssertEqual(markedTextRange.length, 9ul);
798 
799  // Replaces space with space.
800  [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(4, 1)] withText:@" "];
801  selectedTextRange = ((FlutterTextRange*)inputView.selectedTextRange).range;
802 
803  XCTAssertEqual(selectedTextRange.location, 5ul);
804  XCTAssertEqual(selectedTextRange.length, 0ul);
805  XCTAssertEqual(inputView.markedTextRange, nil);
806 }
807 
808 - (void)testFlutterTextInputViewOnlyRespondsToInsertionPointColorBelowIOS17 {
809  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
810  BOOL respondsToInsertionPointColor =
811  [inputView respondsToSelector:@selector(insertionPointColor)];
812  if (@available(iOS 17, *)) {
813  XCTAssertFalse(respondsToInsertionPointColor);
814  } else {
815  XCTAssertTrue(respondsToInsertionPointColor);
816  }
817 }
818 
819 #pragma mark - TextEditingDelta tests
820 - (void)testTextEditingDeltasAreGeneratedOnTextInput {
821  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
822  inputView.enableDeltaModel = YES;
823 
824  __block int updateCount = 0;
825 
826  [inputView insertText:@"text to insert"];
827  OCMExpect(
828  [engine
829  flutterTextInputView:inputView
830  updateEditingClient:0
831  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
832  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
833  isEqualToString:@""]) &&
834  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
835  isEqualToString:@"text to insert"]) &&
836  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) &&
837  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 0);
838  }]])
839  .andDo(^(NSInvocation* invocation) {
840  updateCount++;
841  });
842  XCTAssertEqual(updateCount, 0);
843 
844  [self flushScheduledAsyncBlocks];
845 
846  // Update the framework exactly once.
847  XCTAssertEqual(updateCount, 1);
848 
849  [inputView deleteBackward];
850  OCMExpect([engine flutterTextInputView:inputView
851  updateEditingClient:0
852  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
853  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
854  isEqualToString:@"text to insert"]) &&
855  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
856  isEqualToString:@""]) &&
857  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"]
858  intValue] == 13) &&
859  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"]
860  intValue] == 14);
861  }]])
862  .andDo(^(NSInvocation* invocation) {
863  updateCount++;
864  });
865  [self flushScheduledAsyncBlocks];
866  XCTAssertEqual(updateCount, 2);
867 
868  inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
869  OCMExpect([engine flutterTextInputView:inputView
870  updateEditingClient:0
871  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
872  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
873  isEqualToString:@"text to inser"]) &&
874  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
875  isEqualToString:@""]) &&
876  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"]
877  intValue] == -1) &&
878  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"]
879  intValue] == -1);
880  }]])
881  .andDo(^(NSInvocation* invocation) {
882  updateCount++;
883  });
884  [self flushScheduledAsyncBlocks];
885  XCTAssertEqual(updateCount, 3);
886 
887  [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
888  withText:@"replace text"];
889  OCMExpect(
890  [engine
891  flutterTextInputView:inputView
892  updateEditingClient:0
893  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
894  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
895  isEqualToString:@"text to inser"]) &&
896  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
897  isEqualToString:@"replace text"]) &&
898  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) &&
899  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 1);
900  }]])
901  .andDo(^(NSInvocation* invocation) {
902  updateCount++;
903  });
904  [self flushScheduledAsyncBlocks];
905  XCTAssertEqual(updateCount, 4);
906 
907  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
908  OCMExpect([engine flutterTextInputView:inputView
909  updateEditingClient:0
910  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
911  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
912  isEqualToString:@"replace textext to inser"]) &&
913  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
914  isEqualToString:@"marked text"]) &&
915  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"]
916  intValue] == 12) &&
917  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"]
918  intValue] == 12);
919  }]])
920  .andDo(^(NSInvocation* invocation) {
921  updateCount++;
922  });
923  [self flushScheduledAsyncBlocks];
924  XCTAssertEqual(updateCount, 5);
925 
926  [inputView unmarkText];
927  OCMExpect([engine
928  flutterTextInputView:inputView
929  updateEditingClient:0
930  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
931  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
932  isEqualToString:@"replace textmarked textext to inser"]) &&
933  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
934  isEqualToString:@""]) &&
935  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] ==
936  -1) &&
937  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] ==
938  -1);
939  }]])
940  .andDo(^(NSInvocation* invocation) {
941  updateCount++;
942  });
943  [self flushScheduledAsyncBlocks];
944 
945  XCTAssertEqual(updateCount, 6);
946  OCMVerifyAll(engine);
947 }
948 
949 - (void)testTextEditingDeltasAreBatchedAndForwardedToFramework {
950  // Setup
951  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
952  inputView.enableDeltaModel = YES;
953 
954  // Expected call.
955  OCMExpect([engine flutterTextInputView:inputView
956  updateEditingClient:0
957  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
958  NSArray* deltas = state[@"deltas"];
959  NSDictionary* firstDelta = deltas[0];
960  NSDictionary* secondDelta = deltas[1];
961  NSDictionary* thirdDelta = deltas[2];
962  return [firstDelta[@"oldText"] isEqualToString:@""] &&
963  [firstDelta[@"deltaText"] isEqualToString:@"-"] &&
964  [firstDelta[@"deltaStart"] intValue] == 0 &&
965  [firstDelta[@"deltaEnd"] intValue] == 0 &&
966  [secondDelta[@"oldText"] isEqualToString:@"-"] &&
967  [secondDelta[@"deltaText"] isEqualToString:@""] &&
968  [secondDelta[@"deltaStart"] intValue] == 0 &&
969  [secondDelta[@"deltaEnd"] intValue] == 1 &&
970  [thirdDelta[@"oldText"] isEqualToString:@""] &&
971  [thirdDelta[@"deltaText"] isEqualToString:@"—"] &&
972  [thirdDelta[@"deltaStart"] intValue] == 0 &&
973  [thirdDelta[@"deltaEnd"] intValue] == 0;
974  }]]);
975 
976  // Simulate user input.
977  [inputView insertText:@"-"];
978  [inputView deleteBackward];
979  [inputView insertText:@"—"];
980 
981  [self flushScheduledAsyncBlocks];
982  OCMVerifyAll(engine);
983 }
984 
985 - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextReplacement {
986  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
987  inputView.enableDeltaModel = YES;
988 
989  __block int updateCount = 0;
990  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
991  .andDo(^(NSInvocation* invocation) {
992  updateCount++;
993  });
994 
995  [inputView.text setString:@"Some initial text"];
996  XCTAssertEqual(updateCount, 0);
997 
998  UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
999  inputView.markedTextRange = range;
1000  inputView.selectedTextRange = nil;
1001  [self flushScheduledAsyncBlocks];
1002  XCTAssertEqual(updateCount, 1);
1003 
1004  [inputView setMarkedText:@"new marked text." selectedRange:NSMakeRange(0, 1)];
1005  OCMVerify([engine
1006  flutterTextInputView:inputView
1007  updateEditingClient:0
1008  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1009  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1010  isEqualToString:@"Some initial text"]) &&
1011  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1012  isEqualToString:@"new marked text."]) &&
1013  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
1014  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
1015  }]]);
1016  [self flushScheduledAsyncBlocks];
1017  XCTAssertEqual(updateCount, 2);
1018 }
1019 
1020 - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextInsertion {
1021  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1022  inputView.enableDeltaModel = YES;
1023 
1024  __block int updateCount = 0;
1025  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1026  .andDo(^(NSInvocation* invocation) {
1027  updateCount++;
1028  });
1029 
1030  [inputView.text setString:@"Some initial text"];
1031  [self flushScheduledAsyncBlocks];
1032  XCTAssertEqual(updateCount, 0);
1033 
1034  UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
1035  inputView.markedTextRange = range;
1036  inputView.selectedTextRange = nil;
1037  [self flushScheduledAsyncBlocks];
1038  XCTAssertEqual(updateCount, 1);
1039 
1040  [inputView setMarkedText:@"text." selectedRange:NSMakeRange(0, 1)];
1041  OCMVerify([engine
1042  flutterTextInputView:inputView
1043  updateEditingClient:0
1044  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1045  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1046  isEqualToString:@"Some initial text"]) &&
1047  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1048  isEqualToString:@"text."]) &&
1049  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
1050  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
1051  }]]);
1052  [self flushScheduledAsyncBlocks];
1053  XCTAssertEqual(updateCount, 2);
1054 }
1055 
1056 - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextDeletion {
1057  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1058  inputView.enableDeltaModel = YES;
1059 
1060  __block int updateCount = 0;
1061  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1062  .andDo(^(NSInvocation* invocation) {
1063  updateCount++;
1064  });
1065 
1066  [inputView.text setString:@"Some initial text"];
1067  [self flushScheduledAsyncBlocks];
1068  XCTAssertEqual(updateCount, 0);
1069 
1070  UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
1071  inputView.markedTextRange = range;
1072  inputView.selectedTextRange = nil;
1073  [self flushScheduledAsyncBlocks];
1074  XCTAssertEqual(updateCount, 1);
1075 
1076  [inputView setMarkedText:@"tex" selectedRange:NSMakeRange(0, 1)];
1077  OCMVerify([engine
1078  flutterTextInputView:inputView
1079  updateEditingClient:0
1080  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1081  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1082  isEqualToString:@"Some initial text"]) &&
1083  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1084  isEqualToString:@"tex"]) &&
1085  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
1086  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
1087  }]]);
1088  [self flushScheduledAsyncBlocks];
1089  XCTAssertEqual(updateCount, 2);
1090 }
1091 
1092 #pragma mark - EditingState tests
1093 
1094 - (void)testUITextInputCallsUpdateEditingStateOnce {
1095  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1096 
1097  __block int updateCount = 0;
1098  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
1099  .andDo(^(NSInvocation* invocation) {
1100  updateCount++;
1101  });
1102 
1103  [inputView insertText:@"text to insert"];
1104  // Update the framework exactly once.
1105  XCTAssertEqual(updateCount, 1);
1106 
1107  [inputView deleteBackward];
1108  XCTAssertEqual(updateCount, 2);
1109 
1110  inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1111  XCTAssertEqual(updateCount, 3);
1112 
1113  [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
1114  withText:@"replace text"];
1115  XCTAssertEqual(updateCount, 4);
1116 
1117  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1118  XCTAssertEqual(updateCount, 5);
1119 
1120  [inputView unmarkText];
1121  XCTAssertEqual(updateCount, 6);
1122 }
1123 
1124 - (void)testUITextInputCallsUpdateEditingStateWithDeltaOnce {
1125  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1126  inputView.enableDeltaModel = YES;
1127 
1128  __block int updateCount = 0;
1129  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1130  .andDo(^(NSInvocation* invocation) {
1131  updateCount++;
1132  });
1133 
1134  [inputView insertText:@"text to insert"];
1135  [self flushScheduledAsyncBlocks];
1136  // Update the framework exactly once.
1137  XCTAssertEqual(updateCount, 1);
1138 
1139  [inputView deleteBackward];
1140  [self flushScheduledAsyncBlocks];
1141  XCTAssertEqual(updateCount, 2);
1142 
1143  inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1144  [self flushScheduledAsyncBlocks];
1145  XCTAssertEqual(updateCount, 3);
1146 
1147  [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
1148  withText:@"replace text"];
1149  [self flushScheduledAsyncBlocks];
1150  XCTAssertEqual(updateCount, 4);
1151 
1152  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1153  [self flushScheduledAsyncBlocks];
1154  XCTAssertEqual(updateCount, 5);
1155 
1156  [inputView unmarkText];
1157  [self flushScheduledAsyncBlocks];
1158  XCTAssertEqual(updateCount, 6);
1159 }
1160 
1161 - (void)testTextChangesDoNotTriggerUpdateEditingClient {
1162  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1163 
1164  __block int updateCount = 0;
1165  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
1166  .andDo(^(NSInvocation* invocation) {
1167  updateCount++;
1168  });
1169 
1170  [inputView.text setString:@"BEFORE"];
1171  XCTAssertEqual(updateCount, 0);
1172 
1173  inputView.markedTextRange = nil;
1174  inputView.selectedTextRange = nil;
1175  XCTAssertEqual(updateCount, 1);
1176 
1177  // Text changes don't trigger an update.
1178  XCTAssertEqual(updateCount, 1);
1179  [inputView setTextInputState:@{@"text" : @"AFTER"}];
1180  XCTAssertEqual(updateCount, 1);
1181  [inputView setTextInputState:@{@"text" : @"AFTER"}];
1182  XCTAssertEqual(updateCount, 1);
1183 
1184  // Selection changes don't trigger an update.
1185  [inputView
1186  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}];
1187  XCTAssertEqual(updateCount, 1);
1188  [inputView
1189  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}];
1190  XCTAssertEqual(updateCount, 1);
1191 
1192  // Composing region changes don't trigger an update.
1193  [inputView
1194  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}];
1195  XCTAssertEqual(updateCount, 1);
1196  [inputView
1197  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1198  XCTAssertEqual(updateCount, 1);
1199 }
1200 
1201 - (void)testTextChangesDoNotTriggerUpdateEditingClientWithDelta {
1202  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1203  inputView.enableDeltaModel = YES;
1204 
1205  __block int updateCount = 0;
1206  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1207  .andDo(^(NSInvocation* invocation) {
1208  updateCount++;
1209  });
1210 
1211  [inputView.text setString:@"BEFORE"];
1212  [self flushScheduledAsyncBlocks];
1213  XCTAssertEqual(updateCount, 0);
1214 
1215  inputView.markedTextRange = nil;
1216  inputView.selectedTextRange = nil;
1217  [self flushScheduledAsyncBlocks];
1218  XCTAssertEqual(updateCount, 1);
1219 
1220  // Text changes don't trigger an update.
1221  XCTAssertEqual(updateCount, 1);
1222  [inputView setTextInputState:@{@"text" : @"AFTER"}];
1223  [self flushScheduledAsyncBlocks];
1224  XCTAssertEqual(updateCount, 1);
1225 
1226  [inputView setTextInputState:@{@"text" : @"AFTER"}];
1227  [self flushScheduledAsyncBlocks];
1228  XCTAssertEqual(updateCount, 1);
1229 
1230  // Selection changes don't trigger an update.
1231  [inputView
1232  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}];
1233  [self flushScheduledAsyncBlocks];
1234  XCTAssertEqual(updateCount, 1);
1235 
1236  [inputView
1237  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}];
1238  [self flushScheduledAsyncBlocks];
1239  XCTAssertEqual(updateCount, 1);
1240 
1241  // Composing region changes don't trigger an update.
1242  [inputView
1243  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}];
1244  [self flushScheduledAsyncBlocks];
1245  XCTAssertEqual(updateCount, 1);
1246 
1247  [inputView
1248  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1249  [self flushScheduledAsyncBlocks];
1250  XCTAssertEqual(updateCount, 1);
1251 }
1252 
1253 - (void)testUITextInputAvoidUnnecessaryUndateEditingClientCalls {
1254  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1255 
1256  __block int updateCount = 0;
1257  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
1258  .andDo(^(NSInvocation* invocation) {
1259  updateCount++;
1260  });
1261 
1262  [inputView unmarkText];
1263  // updateEditingClient shouldn't fire as the text is already unmarked.
1264  XCTAssertEqual(updateCount, 0);
1265 
1266  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1267  // updateEditingClient fires in response to setMarkedText.
1268  XCTAssertEqual(updateCount, 1);
1269 
1270  [inputView unmarkText];
1271  // updateEditingClient fires in response to unmarkText.
1272  XCTAssertEqual(updateCount, 2);
1273 }
1274 
1275 - (void)testCanCopyPasteWithScribbleEnabled {
1276  if (@available(iOS 14.0, *)) {
1277  NSDictionary* config = self.mutableTemplateCopy;
1278  [self setClientId:123 configuration:config];
1279  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
1280  FlutterTextInputView* inputView = inputFields[0];
1281 
1282  FlutterTextInputView* mockInputView = OCMPartialMock(inputView);
1283  OCMStub([mockInputView isScribbleAvailable]).andReturn(YES);
1284 
1285  [mockInputView insertText:@"aaaa"];
1286  [mockInputView selectAll:nil];
1287 
1288  XCTAssertFalse([mockInputView canPerformAction:@selector(copy:) withSender:NULL]);
1289  XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:@"sender"]);
1290  XCTAssertFalse([mockInputView canPerformAction:@selector(paste:) withSender:NULL]);
1291  XCTAssertFalse([mockInputView canPerformAction:@selector(paste:) withSender:@"sender"]);
1292 
1293  [mockInputView copy:NULL];
1294  XCTAssertFalse([mockInputView canPerformAction:@selector(copy:) withSender:NULL]);
1295  XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:@"sender"]);
1296  XCTAssertFalse([mockInputView canPerformAction:@selector(paste:) withSender:NULL]);
1297  XCTAssertTrue([mockInputView canPerformAction:@selector(paste:) withSender:@"sender"]);
1298  }
1299 }
1300 
1301 - (void)testSetMarkedTextDuringScribbleDoesNotTriggerUpdateEditingClient {
1302  if (@available(iOS 14.0, *)) {
1303  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1304 
1305  __block int updateCount = 0;
1306  OCMStub([engine flutterTextInputView:inputView
1307  updateEditingClient:0
1308  withState:[OCMArg isNotNil]])
1309  .andDo(^(NSInvocation* invocation) {
1310  updateCount++;
1311  });
1312 
1313  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1314  // updateEditingClient fires in response to setMarkedText.
1315  XCTAssertEqual(updateCount, 1);
1316 
1317  UIScribbleInteraction* scribbleInteraction =
1318  [[UIScribbleInteraction alloc] initWithDelegate:inputView];
1319 
1320  [inputView scribbleInteractionWillBeginWriting:scribbleInteraction];
1321  [inputView setMarkedText:@"during writing" selectedRange:NSMakeRange(1, 2)];
1322  // updateEditingClient does not fire in response to setMarkedText during a scribble interaction.
1323  XCTAssertEqual(updateCount, 1);
1324 
1325  [inputView scribbleInteractionDidFinishWriting:scribbleInteraction];
1326  [inputView resetScribbleInteractionStatusIfEnding];
1327  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1328  // updateEditingClient fires in response to setMarkedText.
1329  XCTAssertEqual(updateCount, 2);
1330 
1331  inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing;
1332  [inputView setMarkedText:@"during focus" selectedRange:NSMakeRange(1, 2)];
1333  // updateEditingClient does not fire in response to setMarkedText during a scribble-initiated
1334  // focus.
1335  XCTAssertEqual(updateCount, 2);
1336 
1337  inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused;
1338  [inputView setMarkedText:@"after focus" selectedRange:NSMakeRange(2, 3)];
1339  // updateEditingClient does not fire in response to setMarkedText after a scribble-initiated
1340  // focus.
1341  XCTAssertEqual(updateCount, 2);
1342 
1343  inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
1344  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1345  // updateEditingClient fires in response to setMarkedText.
1346  XCTAssertEqual(updateCount, 3);
1347  }
1348 }
1349 
1350 - (void)testUpdateEditingClientNegativeSelection {
1351  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1352 
1353  [inputView.text setString:@"SELECTION"];
1354  inputView.markedTextRange = nil;
1355  inputView.selectedTextRange = nil;
1356 
1357  [inputView setTextInputState:@{
1358  @"text" : @"SELECTION",
1359  @"selectionBase" : @-1,
1360  @"selectionExtent" : @-1
1361  }];
1362  [inputView updateEditingState];
1363  OCMVerify([engine flutterTextInputView:inputView
1364  updateEditingClient:0
1365  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1366  return ([state[@"selectionBase"] intValue]) == 0 &&
1367  ([state[@"selectionExtent"] intValue] == 0);
1368  }]]);
1369 
1370  // Returns (0, 0) when either end goes below 0.
1371  [inputView
1372  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @-1, @"selectionExtent" : @1}];
1373  [inputView updateEditingState];
1374  OCMVerify([engine flutterTextInputView:inputView
1375  updateEditingClient:0
1376  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1377  return ([state[@"selectionBase"] intValue]) == 0 &&
1378  ([state[@"selectionExtent"] intValue] == 0);
1379  }]]);
1380 
1381  [inputView
1382  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @-1}];
1383  [inputView updateEditingState];
1384  OCMVerify([engine flutterTextInputView:inputView
1385  updateEditingClient:0
1386  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1387  return ([state[@"selectionBase"] intValue]) == 0 &&
1388  ([state[@"selectionExtent"] intValue] == 0);
1389  }]]);
1390 }
1391 
1392 - (void)testUpdateEditingClientSelectionClamping {
1393  // Regression test for https://github.com/flutter/flutter/issues/62992.
1394  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1395 
1396  [inputView.text setString:@"SELECTION"];
1397  inputView.markedTextRange = nil;
1398  inputView.selectedTextRange = nil;
1399 
1400  [inputView
1401  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @0}];
1402  [inputView updateEditingState];
1403  OCMVerify([engine flutterTextInputView:inputView
1404  updateEditingClient:0
1405  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1406  return ([state[@"selectionBase"] intValue]) == 0 &&
1407  ([state[@"selectionExtent"] intValue] == 0);
1408  }]]);
1409 
1410  // Needs clamping.
1411  [inputView setTextInputState:@{
1412  @"text" : @"SELECTION",
1413  @"selectionBase" : @0,
1414  @"selectionExtent" : @9999
1415  }];
1416  [inputView updateEditingState];
1417 
1418  OCMVerify([engine flutterTextInputView:inputView
1419  updateEditingClient:0
1420  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1421  return ([state[@"selectionBase"] intValue]) == 0 &&
1422  ([state[@"selectionExtent"] intValue] == 9);
1423  }]]);
1424 
1425  // No clamping needed, but in reverse direction.
1426  [inputView
1427  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @0}];
1428  [inputView updateEditingState];
1429  OCMVerify([engine flutterTextInputView:inputView
1430  updateEditingClient:0
1431  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1432  return ([state[@"selectionBase"] intValue]) == 0 &&
1433  ([state[@"selectionExtent"] intValue] == 1);
1434  }]]);
1435 
1436  // Both ends need clamping.
1437  [inputView setTextInputState:@{
1438  @"text" : @"SELECTION",
1439  @"selectionBase" : @9999,
1440  @"selectionExtent" : @9999
1441  }];
1442  [inputView updateEditingState];
1443  OCMVerify([engine flutterTextInputView:inputView
1444  updateEditingClient:0
1445  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1446  return ([state[@"selectionBase"] intValue]) == 9 &&
1447  ([state[@"selectionExtent"] intValue] == 9);
1448  }]]);
1449 }
1450 
1451 - (void)testInputViewsHasNonNilInputDelegate {
1452  if (@available(iOS 13.0, *)) {
1453  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1454  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
1455 
1456  [inputView setTextInputClient:123];
1457  [inputView reloadInputViews];
1458  [inputView becomeFirstResponder];
1459  NSAssert(inputView.isFirstResponder, @"inputView is not first responder");
1460  inputView.inputDelegate = nil;
1461 
1462  FlutterTextInputView* mockInputView = OCMPartialMock(inputView);
1463  [mockInputView setTextInputState:@{
1464  @"text" : @"COMPOSING",
1465  @"composingBase" : @1,
1466  @"composingExtent" : @3
1467  }];
1468  OCMVerify([mockInputView setInputDelegate:[OCMArg isNotNil]]);
1469  [inputView removeFromSuperview];
1470  }
1471 }
1472 
1473 - (void)testInputViewsDoNotHaveUITextInteractions {
1474  if (@available(iOS 13.0, *)) {
1475  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1476  BOOL hasTextInteraction = NO;
1477  for (id interaction in inputView.interactions) {
1478  hasTextInteraction = [interaction isKindOfClass:[UITextInteraction class]];
1479  if (hasTextInteraction) {
1480  break;
1481  }
1482  }
1483  XCTAssertFalse(hasTextInteraction);
1484  }
1485 }
1486 
1487 #pragma mark - UITextInput methods - Tests
1488 
1489 - (void)testUpdateFirstRectForRange {
1490  [self setClientId:123 configuration:self.mutableTemplateCopy];
1491 
1492  FlutterTextInputView* inputView = textInputPlugin.activeView;
1493  textInputPlugin.viewController.view.frame = CGRectMake(0, 0, 0, 0);
1494 
1495  [inputView
1496  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1497 
1498  CGRect kInvalidFirstRect = CGRectMake(-1, -1, 9999, 9999);
1499  FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1500  // yOffset = 200.
1501  NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];
1502  NSArray* zeroMatrix = @[ @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0 ];
1503  // This matrix can be generated by running this dart code snippet:
1504  // Matrix4.identity()..scale(3.0)..rotateZ(math.pi/2)..translate(1.0, 2.0,
1505  // 3.0);
1506  NSArray* affineMatrix = @[
1507  @(0.0), @(3.0), @(0.0), @(0.0), @(-3.0), @(0.0), @(0.0), @(0.0), @(0.0), @(0.0), @(3.0), @(0.0),
1508  @(-6.0), @(3.0), @(9.0), @(1.0)
1509  ];
1510 
1511  // Invalid since we don't have the transform or the rect.
1512  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1513 
1514  [inputView setEditableTransform:yOffsetMatrix];
1515  // Invalid since we don't have the rect.
1516  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1517 
1518  // Valid rect and transform.
1519  CGRect testRect = CGRectMake(0, 0, 100, 100);
1520  [inputView setMarkedRect:testRect];
1521 
1522  CGRect finalRect = CGRectOffset(testRect, 0, 200);
1523  XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
1524  // Idempotent.
1525  XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
1526 
1527  // Use an invalid matrix:
1528  [inputView setEditableTransform:zeroMatrix];
1529  // Invalid matrix is invalid.
1530  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1531  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1532 
1533  // Revert the invalid matrix change.
1534  [inputView setEditableTransform:yOffsetMatrix];
1535  [inputView setMarkedRect:testRect];
1536  XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
1537 
1538  // Use an invalid rect:
1539  [inputView setMarkedRect:kInvalidFirstRect];
1540  // Invalid marked rect is invalid.
1541  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1542  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1543 
1544  // Use a 3d affine transform that does 3d-scaling, z-index rotating and 3d translation.
1545  [inputView setEditableTransform:affineMatrix];
1546  [inputView setMarkedRect:testRect];
1547  XCTAssertTrue(
1548  CGRectEqualToRect(CGRectMake(-306, 3, 300, 300), [inputView firstRectForRange:range]));
1549 
1550  NSAssert(inputView.superview, @"inputView is not in the view hierarchy!");
1551  const CGPoint offset = CGPointMake(113, 119);
1552  CGRect currentFrame = inputView.frame;
1553  currentFrame.origin = offset;
1554  inputView.frame = currentFrame;
1555  // Moving the input view within the FlutterView shouldn't affect the coordinates,
1556  // since the framework sends us global coordinates.
1557  XCTAssertTrue(CGRectEqualToRect(CGRectMake(-306 - 113, 3 - 119, 300, 300),
1558  [inputView firstRectForRange:range]));
1559 }
1560 
1561 - (void)testFirstRectForRangeReturnsNoneZeroRectWhenScribbleIsEnabled {
1562  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1563  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1564 
1565  FlutterTextInputView* mockInputView = OCMPartialMock(inputView);
1566  OCMStub([mockInputView isScribbleAvailable]).andReturn(YES);
1567 
1568  [inputView setSelectionRects:@[
1569  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1570  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1571  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1572  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1573  ]];
1574 
1575  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1576 
1577  if (@available(iOS 17, *)) {
1578  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1579  [inputView firstRectForRange:multiRectRange]));
1580  } else {
1581  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100),
1582  [inputView firstRectForRange:multiRectRange]));
1583  }
1584 }
1585 
1586 - (void)testFirstRectForRangeReturnsCorrectRectOnASingleLineLeftToRight {
1587  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1588  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1589 
1590  [inputView setSelectionRects:@[
1591  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1592  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1593  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1594  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1595  ]];
1596  FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1597  if (@available(iOS 17, *)) {
1598  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100),
1599  [inputView firstRectForRange:singleRectRange]));
1600  } else {
1601  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1602  }
1603 
1604  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1605 
1606  if (@available(iOS 17, *)) {
1607  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1608  [inputView firstRectForRange:multiRectRange]));
1609  } else {
1610  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1611  }
1612 
1613  [inputView setTextInputState:@{@"text" : @"COM"}];
1614  FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)];
1615  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds]));
1616 }
1617 
1618 - (void)testFirstRectForRangeReturnsCorrectRectOnASingleLineRightToLeft {
1619  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1620  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1621 
1622  [inputView setSelectionRects:@[
1623  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1624  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U],
1625  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U],
1626  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1627  ]];
1628  FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1629  if (@available(iOS 17, *)) {
1630  XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100),
1631  [inputView firstRectForRange:singleRectRange]));
1632  } else {
1633  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1634  }
1635 
1636  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1637  if (@available(iOS 17, *)) {
1638  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100),
1639  [inputView firstRectForRange:multiRectRange]));
1640  } else {
1641  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1642  }
1643 
1644  [inputView setTextInputState:@{@"text" : @"COM"}];
1645  FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)];
1646  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds]));
1647 }
1648 
1649 - (void)testFirstRectForRangeReturnsCorrectRectOnMultipleLinesLeftToRight {
1650  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1651  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1652 
1653  [inputView setSelectionRects:@[
1654  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1655  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1656  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1657  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1658  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:4U],
1659  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:5U],
1660  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:6U],
1661  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 100, 100, 100) position:7U],
1662  ]];
1663  FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1664  if (@available(iOS 17, *)) {
1665  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100),
1666  [inputView firstRectForRange:singleRectRange]));
1667  } else {
1668  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1669  }
1670 
1671  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1672 
1673  if (@available(iOS 17, *)) {
1674  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1675  [inputView firstRectForRange:multiRectRange]));
1676  } else {
1677  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1678  }
1679 }
1680 
1681 - (void)testFirstRectForRangeReturnsCorrectRectOnMultipleLinesRightToLeft {
1682  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1683  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1684 
1685  [inputView setSelectionRects:@[
1686  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1687  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U],
1688  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U],
1689  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1690  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 100, 100, 100) position:4U],
1691  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:5U],
1692  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:6U],
1693  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:7U],
1694  ]];
1695  FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1696  if (@available(iOS 17, *)) {
1697  XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100),
1698  [inputView firstRectForRange:singleRectRange]));
1699  } else {
1700  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1701  }
1702 
1703  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1704  if (@available(iOS 17, *)) {
1705  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100),
1706  [inputView firstRectForRange:multiRectRange]));
1707  } else {
1708  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1709  }
1710 }
1711 
1712 - (void)testFirstRectForRangeReturnsCorrectRectOnSingleLineWithVaryingMinYAndMaxYLeftToRight {
1713  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1714  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1715 
1716  [inputView setSelectionRects:@[
1717  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1718  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 10, 100, 80)
1719  position:1U], // shorter
1720  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, -10, 100, 120)
1721  position:2U], // taller
1722  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1723  ]];
1724 
1725  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1726 
1727  if (@available(iOS 17, *)) {
1728  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, -10, 300, 120),
1729  [inputView firstRectForRange:multiRectRange]));
1730  } else {
1731  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1732  }
1733 }
1734 
1735 - (void)testFirstRectForRangeReturnsCorrectRectOnSingleLineWithVaryingMinYAndMaxYRightToLeft {
1736  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1737  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1738 
1739  [inputView setSelectionRects:@[
1740  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1741  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, -10, 100, 120)
1742  position:1U], // taller
1743  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 10, 100, 80)
1744  position:2U], // shorter
1745  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1746  ]];
1747 
1748  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1749 
1750  if (@available(iOS 17, *)) {
1751  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, -10, 300, 120),
1752  [inputView firstRectForRange:multiRectRange]));
1753  } else {
1754  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1755  }
1756 }
1757 
1758 - (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsExceedingThresholdLeftToRight {
1759  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1760  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1761 
1762  [inputView setSelectionRects:@[
1763  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1764  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1765  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1766  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1767  // y=60 exceeds threshold, so treat it as a new line.
1768  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 60, 100, 100) position:4U],
1769  ]];
1770 
1771  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1772 
1773  if (@available(iOS 17, *)) {
1774  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1775  [inputView firstRectForRange:multiRectRange]));
1776  } else {
1777  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1778  }
1779 }
1780 
1781 - (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsExceedingThresholdRightToLeft {
1782  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1783  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1784 
1785  [inputView setSelectionRects:@[
1786  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1787  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U],
1788  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U],
1789  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1790  // y=60 exceeds threshold, so treat it as a new line.
1791  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 60, 100, 100) position:4U],
1792  ]];
1793 
1794  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1795 
1796  if (@available(iOS 17, *)) {
1797  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100),
1798  [inputView firstRectForRange:multiRectRange]));
1799  } else {
1800  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1801  }
1802 }
1803 
1804 - (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsWithinThresholdLeftToRight {
1805  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1806  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1807 
1808  [inputView setSelectionRects:@[
1809  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1810  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1811  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1812  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1813  // y=40 is within line threshold, so treat it as the same line
1814  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(400, 40, 100, 100) position:4U],
1815  ]];
1816 
1817  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1818 
1819  if (@available(iOS 17, *)) {
1820  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 400, 140),
1821  [inputView firstRectForRange:multiRectRange]));
1822  } else {
1823  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1824  }
1825 }
1826 
1827 - (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsWithinThresholdRightToLeft {
1828  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1829  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1830 
1831  [inputView setSelectionRects:@[
1832  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(400, 0, 100, 100) position:0U],
1833  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:1U],
1834  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1835  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:3U],
1836  // y=40 is within line threshold, so treat it as the same line
1837  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 40, 100, 100) position:4U],
1838  ]];
1839 
1840  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1841 
1842  if (@available(iOS 17, *)) {
1843  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 400, 140),
1844  [inputView firstRectForRange:multiRectRange]));
1845  } else {
1846  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1847  }
1848 }
1849 
1850 - (void)testClosestPositionToPoint {
1851  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1852  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1853 
1854  // Minimize the vertical distance from the center of the rects first
1855  [inputView setSelectionRects:@[
1856  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1857  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
1858  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:2U],
1859  ]];
1860  CGPoint point = CGPointMake(150, 150);
1861  XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
1862  XCTAssertEqual(UITextStorageDirectionBackward,
1863  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
1864 
1865  // Then, if the point is above the bottom of the closest rects vertically, get the closest x
1866  // origin
1867  [inputView setSelectionRects:@[
1868  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1869  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
1870  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
1871  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
1872  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
1873  ]];
1874  point = CGPointMake(125, 150);
1875  XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
1876  XCTAssertEqual(UITextStorageDirectionForward,
1877  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
1878 
1879  // However, if the point is below the bottom of the closest rects vertically, get the position
1880  // farthest to the right
1881  [inputView setSelectionRects:@[
1882  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1883  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
1884  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
1885  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
1886  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 300, 100, 100) position:4U],
1887  ]];
1888  point = CGPointMake(125, 201);
1889  XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
1890  XCTAssertEqual(UITextStorageDirectionBackward,
1891  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
1892 
1893  // Also check a point at the right edge of the last selection rect
1894  [inputView setSelectionRects:@[
1895  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1896  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
1897  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
1898  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
1899  ]];
1900  point = CGPointMake(125, 250);
1901  XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
1902  XCTAssertEqual(UITextStorageDirectionBackward,
1903  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
1904 
1905  // Minimize vertical distance if the difference is more than 1 point.
1906  [inputView setSelectionRects:@[
1907  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 2, 100, 100) position:0U],
1908  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 2, 100, 100) position:1U],
1909  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1910  ]];
1911  point = CGPointMake(110, 50);
1912  XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
1913  XCTAssertEqual(UITextStorageDirectionForward,
1914  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
1915 
1916  // In floating cursor mode, the vertical difference is allowed to be 10 points.
1917  // The closest horizontal position will now win.
1918  [inputView beginFloatingCursorAtPoint:CGPointZero];
1919  XCTAssertEqual(1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
1920  XCTAssertEqual(UITextStorageDirectionForward,
1921  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
1922  [inputView endFloatingCursor];
1923 }
1924 
1925 - (void)testClosestPositionToPointRTL {
1926  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1927  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1928 
1929  [inputView setSelectionRects:@[
1930  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100)
1931  position:0U
1932  writingDirection:NSWritingDirectionRightToLeft],
1933  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100)
1934  position:1U
1935  writingDirection:NSWritingDirectionRightToLeft],
1936  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100)
1937  position:2U
1938  writingDirection:NSWritingDirectionRightToLeft],
1939  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100)
1940  position:3U
1941  writingDirection:NSWritingDirectionRightToLeft],
1942  ]];
1943  FlutterTextPosition* position =
1944  (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(275, 50)];
1945  XCTAssertEqual(0U, position.index);
1946  XCTAssertEqual(UITextStorageDirectionForward, position.affinity);
1947  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(225, 50)];
1948  XCTAssertEqual(1U, position.index);
1949  XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
1950  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(175, 50)];
1951  XCTAssertEqual(1U, position.index);
1952  XCTAssertEqual(UITextStorageDirectionForward, position.affinity);
1953  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(125, 50)];
1954  XCTAssertEqual(2U, position.index);
1955  XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
1956  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(75, 50)];
1957  XCTAssertEqual(2U, position.index);
1958  XCTAssertEqual(UITextStorageDirectionForward, position.affinity);
1959  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(25, 50)];
1960  XCTAssertEqual(3U, position.index);
1961  XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
1962  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(-25, 50)];
1963  XCTAssertEqual(3U, position.index);
1964  XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
1965 }
1966 
1967 - (void)testSelectionRectsForRange {
1968  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1969  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1970 
1971  CGRect testRect0 = CGRectMake(100, 100, 100, 100);
1972  CGRect testRect1 = CGRectMake(200, 200, 100, 100);
1973  [inputView setSelectionRects:@[
1974  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1977  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U],
1978  ]];
1979 
1980  // Returns the matching rects within a range
1981  FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 2)];
1982  XCTAssertTrue(CGRectEqualToRect(testRect0, [inputView selectionRectsForRange:range][0].rect));
1983  XCTAssertTrue(CGRectEqualToRect(testRect1, [inputView selectionRectsForRange:range][1].rect));
1984  XCTAssertEqual(2U, [[inputView selectionRectsForRange:range] count]);
1985 
1986  // Returns a 0 width rect for a 0-length range
1987  range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 0)];
1988  XCTAssertEqual(1U, [[inputView selectionRectsForRange:range] count]);
1989  XCTAssertTrue(CGRectEqualToRect(
1990  CGRectMake(testRect0.origin.x, testRect0.origin.y, 0, testRect0.size.height),
1991  [inputView selectionRectsForRange:range][0].rect));
1992 }
1993 
1994 - (void)testClosestPositionToPointWithinRange {
1995  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1996  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1997 
1998  // Do not return a position before the start of the range
1999  [inputView setSelectionRects:@[
2000  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2001  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2002  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2003  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2004  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
2005  ]];
2006  CGPoint point = CGPointMake(125, 150);
2007  FlutterTextRange* range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(3, 2)] copy];
2008  XCTAssertEqual(
2009  3U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index);
2010  XCTAssertEqual(
2011  UITextStorageDirectionForward,
2012  ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).affinity);
2013 
2014  // Do not return a position after the end of the range
2015  [inputView setSelectionRects:@[
2016  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2017  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2018  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2019  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2020  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
2021  ]];
2022  point = CGPointMake(125, 150);
2023  range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] copy];
2024  XCTAssertEqual(
2025  1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index);
2026  XCTAssertEqual(
2027  UITextStorageDirectionForward,
2028  ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).affinity);
2029 }
2030 
2031 - (void)testClosestPositionToPointWithPartialSelectionRects {
2032  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2033  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2034 
2035  [inputView setSelectionRects:@[ [FlutterTextSelectionRect
2036  selectionRectWithRect:CGRectMake(0, 0, 100, 100)
2037  position:0U] ]];
2038  // Asking with a position at the end of selection rects should give you the trailing edge of
2039  // the last rect.
2040  XCTAssertTrue(CGRectEqualToRect(
2042  positionWithIndex:1
2043  affinity:UITextStorageDirectionForward]],
2044  CGRectMake(100, 0, 0, 100)));
2045  // Asking with a position beyond the end of selection rects should return CGRectZero without
2046  // crashing.
2047  XCTAssertTrue(CGRectEqualToRect(
2049  positionWithIndex:2
2050  affinity:UITextStorageDirectionForward]],
2051  CGRectZero));
2052 }
2053 
2054 #pragma mark - Floating Cursor - Tests
2055 
2056 - (void)testFloatingCursorDoesNotThrow {
2057  // The keyboard implementation may send unbalanced calls to the input view.
2058  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2059  [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2060  [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2061  [inputView endFloatingCursor];
2062  [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2063  [inputView endFloatingCursor];
2064 }
2065 
2066 - (void)testFloatingCursor {
2067  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2068  [inputView setTextInputState:@{
2069  @"text" : @"test",
2070  @"selectionBase" : @1,
2071  @"selectionExtent" : @1,
2072  }];
2073 
2074  FlutterTextSelectionRect* first =
2075  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U];
2076  FlutterTextSelectionRect* second =
2077  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:1U];
2078  FlutterTextSelectionRect* third =
2079  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U];
2080  FlutterTextSelectionRect* fourth =
2081  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U];
2082  [inputView setSelectionRects:@[ first, second, third, fourth ]];
2083 
2084  // Verify zeroth caret rect is based on left edge of first character.
2085  XCTAssertTrue(CGRectEqualToRect(
2087  positionWithIndex:0
2088  affinity:UITextStorageDirectionForward]],
2089  CGRectMake(0, 0, 0, 100)));
2090  // Since the textAffinity is downstream, the caret rect will be based on the
2091  // left edge of the succeeding character.
2092  XCTAssertTrue(CGRectEqualToRect(
2094  positionWithIndex:1
2095  affinity:UITextStorageDirectionForward]],
2096  CGRectMake(100, 100, 0, 100)));
2097  XCTAssertTrue(CGRectEqualToRect(
2099  positionWithIndex:2
2100  affinity:UITextStorageDirectionForward]],
2101  CGRectMake(200, 200, 0, 100)));
2102  XCTAssertTrue(CGRectEqualToRect(
2104  positionWithIndex:3
2105  affinity:UITextStorageDirectionForward]],
2106  CGRectMake(300, 300, 0, 100)));
2107  // There is no subsequent character for the last position, so the caret rect
2108  // will be based on the right edge of the preceding character.
2109  XCTAssertTrue(CGRectEqualToRect(
2111  positionWithIndex:4
2112  affinity:UITextStorageDirectionForward]],
2113  CGRectMake(400, 300, 0, 100)));
2114  // Verify no caret rect for out-of-range character.
2115  XCTAssertTrue(CGRectEqualToRect(
2117  positionWithIndex:5
2118  affinity:UITextStorageDirectionForward]],
2119  CGRectZero));
2120 
2121  // Check caret rects again again when text affinity is upstream.
2122  [inputView setTextInputState:@{
2123  @"text" : @"test",
2124  @"selectionBase" : @2,
2125  @"selectionExtent" : @2,
2126  }];
2127  // Verify zeroth caret rect is based on left edge of first character.
2128  XCTAssertTrue(CGRectEqualToRect(
2130  positionWithIndex:0
2131  affinity:UITextStorageDirectionBackward]],
2132  CGRectMake(0, 0, 0, 100)));
2133  // Since the textAffinity is upstream, all below caret rects will be based on
2134  // the right edge of the preceding character.
2135  XCTAssertTrue(CGRectEqualToRect(
2137  positionWithIndex:1
2138  affinity:UITextStorageDirectionBackward]],
2139  CGRectMake(100, 0, 0, 100)));
2140  XCTAssertTrue(CGRectEqualToRect(
2142  positionWithIndex:2
2143  affinity:UITextStorageDirectionBackward]],
2144  CGRectMake(200, 100, 0, 100)));
2145  XCTAssertTrue(CGRectEqualToRect(
2147  positionWithIndex:3
2148  affinity:UITextStorageDirectionBackward]],
2149  CGRectMake(300, 200, 0, 100)));
2150  XCTAssertTrue(CGRectEqualToRect(
2152  positionWithIndex:4
2153  affinity:UITextStorageDirectionBackward]],
2154  CGRectMake(400, 300, 0, 100)));
2155  // Verify no caret rect for out-of-range character.
2156  XCTAssertTrue(CGRectEqualToRect(
2158  positionWithIndex:5
2159  affinity:UITextStorageDirectionBackward]],
2160  CGRectZero));
2161 
2162  // Verify floating cursor updates are relative to original position, and that there is no bounds
2163  // change.
2164  CGRect initialBounds = inputView.bounds;
2165  [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2166  XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
2167  OCMVerify([engine flutterTextInputView:inputView
2168  updateFloatingCursor:FlutterFloatingCursorDragStateStart
2169  withClient:0
2170  withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
2171  return ([state[@"X"] isEqualToNumber:@(0)]) &&
2172  ([state[@"Y"] isEqualToNumber:@(0)]);
2173  }]]);
2174 
2175  [inputView updateFloatingCursorAtPoint:CGPointMake(456, 654)];
2176  XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
2177  OCMVerify([engine flutterTextInputView:inputView
2178  updateFloatingCursor:FlutterFloatingCursorDragStateUpdate
2179  withClient:0
2180  withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
2181  return ([state[@"X"] isEqualToNumber:@(333)]) &&
2182  ([state[@"Y"] isEqualToNumber:@(333)]);
2183  }]]);
2184 
2185  [inputView endFloatingCursor];
2186  XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
2187  OCMVerify([engine flutterTextInputView:inputView
2188  updateFloatingCursor:FlutterFloatingCursorDragStateEnd
2189  withClient:0
2190  withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
2191  return ([state[@"X"] isEqualToNumber:@(0)]) &&
2192  ([state[@"Y"] isEqualToNumber:@(0)]);
2193  }]]);
2194 }
2195 
2196 #pragma mark - UIKeyInput Overrides - Tests
2197 
2198 - (void)testInsertTextAddsPlaceholderSelectionRects {
2199  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2200  [inputView
2201  setTextInputState:@{@"text" : @"test", @"selectionBase" : @1, @"selectionExtent" : @1}];
2202 
2203  FlutterTextSelectionRect* first =
2204  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U];
2205  FlutterTextSelectionRect* second =
2206  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:1U];
2207  FlutterTextSelectionRect* third =
2208  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U];
2209  FlutterTextSelectionRect* fourth =
2210  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U];
2211  [inputView setSelectionRects:@[ first, second, third, fourth ]];
2212 
2213  // Inserts additional selection rects at the selection start
2214  [inputView insertText:@"in"];
2215  NSArray* selectionRects =
2216  [inputView selectionRectsForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 6)]];
2217  XCTAssertEqual(6U, [selectionRects count]);
2218 
2219  XCTAssertEqual(first.position, ((FlutterTextSelectionRect*)selectionRects[0]).position);
2220  XCTAssertTrue(CGRectEqualToRect(first.rect, ((FlutterTextSelectionRect*)selectionRects[0]).rect));
2221 
2222  XCTAssertEqual(second.position, ((FlutterTextSelectionRect*)selectionRects[1]).position);
2223  XCTAssertTrue(
2224  CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[1]).rect));
2225 
2226  XCTAssertEqual(second.position + 1, ((FlutterTextSelectionRect*)selectionRects[2]).position);
2227  XCTAssertTrue(
2228  CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[2]).rect));
2229 
2230  XCTAssertEqual(second.position + 2, ((FlutterTextSelectionRect*)selectionRects[3]).position);
2231  XCTAssertTrue(
2232  CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[3]).rect));
2233 
2234  XCTAssertEqual(third.position + 2, ((FlutterTextSelectionRect*)selectionRects[4]).position);
2235  XCTAssertTrue(CGRectEqualToRect(third.rect, ((FlutterTextSelectionRect*)selectionRects[4]).rect));
2236 
2237  XCTAssertEqual(fourth.position + 2, ((FlutterTextSelectionRect*)selectionRects[5]).position);
2238  XCTAssertTrue(
2239  CGRectEqualToRect(fourth.rect, ((FlutterTextSelectionRect*)selectionRects[5]).rect));
2240 }
2241 
2242 #pragma mark - Autofill - Utilities
2243 
2244 - (NSMutableDictionary*)mutablePasswordTemplateCopy {
2245  if (!_passwordTemplate) {
2246  _passwordTemplate = @{
2247  @"inputType" : @{@"name" : @"TextInuptType.text"},
2248  @"keyboardAppearance" : @"Brightness.light",
2249  @"obscureText" : @YES,
2250  @"inputAction" : @"TextInputAction.unspecified",
2251  @"smartDashesType" : @"0",
2252  @"smartQuotesType" : @"0",
2253  @"autocorrect" : @YES
2254  };
2255  }
2256 
2257  return [_passwordTemplate mutableCopy];
2258 }
2259 
2260 - (NSArray<FlutterTextInputView*>*)viewsVisibleToAutofill {
2261  return [self.installedInputViews
2262  filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisibleToAutofill == YES"]];
2263 }
2264 
2265 - (void)commitAutofillContextAndVerify {
2266  FlutterMethodCall* methodCall =
2267  [FlutterMethodCall methodCallWithMethodName:@"TextInput.finishAutofillContext"
2268  arguments:@YES];
2269  [textInputPlugin handleMethodCall:methodCall
2270  result:^(id _Nullable result){
2271  }];
2272 
2273  XCTAssertEqual(self.viewsVisibleToAutofill.count,
2274  [textInputPlugin.activeView isVisibleToAutofill] ? 1ul : 0ul);
2275  XCTAssertNotEqual(textInputPlugin.textInputView, nil);
2276  // The active view should still be installed so it doesn't get
2277  // deallocated.
2278  XCTAssertEqual(self.installedInputViews.count, 1ul);
2279  XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2280 }
2281 
2282 #pragma mark - Autofill - Tests
2283 
2284 - (void)testDisablingAutofillOnInputClient {
2285  NSDictionary* config = self.mutableTemplateCopy;
2286  [config setValue:@"YES" forKey:@"obscureText"];
2287 
2288  [self setClientId:123 configuration:config];
2289 
2290  FlutterTextInputView* inputView = self.installedInputViews[0];
2291  XCTAssertEqualObjects(inputView.textContentType, @"");
2292 }
2293 
2294 - (void)testAutofillEnabledByDefault {
2295  NSDictionary* config = self.mutableTemplateCopy;
2296  [config setValue:@"NO" forKey:@"obscureText"];
2297  [config setValue:@{@"uniqueIdentifier" : @"field1", @"editingValue" : @{@"text" : @""}}
2298  forKey:@"autofill"];
2299 
2300  [self setClientId:123 configuration:config];
2301 
2302  FlutterTextInputView* inputView = self.installedInputViews[0];
2303  XCTAssertNil(inputView.textContentType);
2304 }
2305 
2306 - (void)testAutofillContext {
2307  NSMutableDictionary* field1 = self.mutableTemplateCopy;
2308 
2309  [field1 setValue:@{
2310  @"uniqueIdentifier" : @"field1",
2311  @"hints" : @[ @"hint1" ],
2312  @"editingValue" : @{@"text" : @""}
2313  }
2314  forKey:@"autofill"];
2315 
2316  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2317  [field2 setValue:@{
2318  @"uniqueIdentifier" : @"field2",
2319  @"hints" : @[ @"hint2" ],
2320  @"editingValue" : @{@"text" : @""}
2321  }
2322  forKey:@"autofill"];
2323 
2324  NSMutableDictionary* config = [field1 mutableCopy];
2325  [config setValue:@[ field1, field2 ] forKey:@"fields"];
2326 
2327  [self setClientId:123 configuration:config];
2328  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2329 
2330  XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2331 
2332  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2333  XCTAssertEqual(self.installedInputViews.count, 2ul);
2334  XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2335  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2336 
2337  // The configuration changes.
2338  NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy;
2339  [field3 setValue:@{
2340  @"uniqueIdentifier" : @"field3",
2341  @"hints" : @[ @"hint3" ],
2342  @"editingValue" : @{@"text" : @""}
2343  }
2344  forKey:@"autofill"];
2345 
2346  NSMutableDictionary* oldContext = textInputPlugin.autofillContext;
2347  // Replace field2 with field3.
2348  [config setValue:@[ field1, field3 ] forKey:@"fields"];
2349 
2350  [self setClientId:123 configuration:config];
2351 
2352  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2353  XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
2354 
2355  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2356  XCTAssertEqual(self.installedInputViews.count, 3ul);
2357  XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2358  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2359 
2360  // Old autofill input fields are still installed and reused.
2361  for (NSString* key in oldContext.allKeys) {
2362  XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
2363  }
2364 
2365  // Switch to a password field that has no contentType and is not in an AutofillGroup.
2366  config = self.mutablePasswordTemplateCopy;
2367 
2368  oldContext = textInputPlugin.autofillContext;
2369  [self setClientId:124 configuration:config];
2370  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2371 
2372  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
2373  XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
2374 
2375  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2376  XCTAssertEqual(self.installedInputViews.count, 4ul);
2377 
2378  // Old autofill input fields are still installed and reused.
2379  for (NSString* key in oldContext.allKeys) {
2380  XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
2381  }
2382  // The active view should change.
2383  XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2384  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2385 
2386  // Switch to a similar password field, the previous field should be reused.
2387  oldContext = textInputPlugin.autofillContext;
2388  [self setClientId:200 configuration:config];
2389 
2390  // Reuse the input view instance from the last time.
2391  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
2392  XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
2393 
2394  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2395  XCTAssertEqual(self.installedInputViews.count, 4ul);
2396 
2397  // Old autofill input fields are still installed and reused.
2398  for (NSString* key in oldContext.allKeys) {
2399  XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
2400  }
2401  XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2402  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2403 }
2404 
2405 - (void)testCommitAutofillContext {
2406  NSMutableDictionary* field1 = self.mutableTemplateCopy;
2407  [field1 setValue:@{
2408  @"uniqueIdentifier" : @"field1",
2409  @"hints" : @[ @"hint1" ],
2410  @"editingValue" : @{@"text" : @""}
2411  }
2412  forKey:@"autofill"];
2413 
2414  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2415  [field2 setValue:@{
2416  @"uniqueIdentifier" : @"field2",
2417  @"hints" : @[ @"hint2" ],
2418  @"editingValue" : @{@"text" : @""}
2419  }
2420  forKey:@"autofill"];
2421 
2422  NSMutableDictionary* field3 = self.mutableTemplateCopy;
2423  [field3 setValue:@{
2424  @"uniqueIdentifier" : @"field3",
2425  @"hints" : @[ @"hint3" ],
2426  @"editingValue" : @{@"text" : @""}
2427  }
2428  forKey:@"autofill"];
2429 
2430  NSMutableDictionary* config = [field1 mutableCopy];
2431  [config setValue:@[ field1, field2 ] forKey:@"fields"];
2432 
2433  [self setClientId:123 configuration:config];
2434  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2435  XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2436  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2437 
2438  [self commitAutofillContextAndVerify];
2439  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2440 
2441  // Install the password field again.
2442  [self setClientId:123 configuration:config];
2443  // Switch to a regular autofill group.
2444  [self setClientId:124 configuration:field3];
2445  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
2446 
2447  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2448  XCTAssertEqual(self.installedInputViews.count, 3ul);
2449  XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2450  XCTAssertNotEqual(textInputPlugin.textInputView, nil);
2451  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2452 
2453  [self commitAutofillContextAndVerify];
2454  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2455 
2456  // Now switch to an input field that does not autofill.
2457  [self setClientId:125 configuration:self.mutableTemplateCopy];
2458 
2459  XCTAssertEqual(self.viewsVisibleToAutofill.count, 0ul);
2460  // The active view should still be installed so it doesn't get
2461  // deallocated.
2462 
2463  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2464  XCTAssertEqual(self.installedInputViews.count, 1ul);
2465  XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2466  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2467 
2468  [self commitAutofillContextAndVerify];
2469  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2470 }
2471 
2472 - (void)testAutofillInputViews {
2473  NSMutableDictionary* field1 = self.mutableTemplateCopy;
2474  [field1 setValue:@{
2475  @"uniqueIdentifier" : @"field1",
2476  @"hints" : @[ @"hint1" ],
2477  @"editingValue" : @{@"text" : @""}
2478  }
2479  forKey:@"autofill"];
2480 
2481  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2482  [field2 setValue:@{
2483  @"uniqueIdentifier" : @"field2",
2484  @"hints" : @[ @"hint2" ],
2485  @"editingValue" : @{@"text" : @""}
2486  }
2487  forKey:@"autofill"];
2488 
2489  NSMutableDictionary* config = [field1 mutableCopy];
2490  [config setValue:@[ field1, field2 ] forKey:@"fields"];
2491 
2492  [self setClientId:123 configuration:config];
2493  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2494 
2495  // Find all the FlutterTextInputViews we created.
2496  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
2497 
2498  // Both fields are installed and visible because it's a password group.
2499  XCTAssertEqual(inputFields.count, 2ul);
2500  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2501 
2502  // Find the inactive autofillable input field.
2503  FlutterTextInputView* inactiveView = inputFields[1];
2504  [inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)]
2505  withText:@"Autofilled!"];
2506  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2507 
2508  // Verify behavior.
2509  OCMVerify([engine flutterTextInputView:inactiveView
2510  updateEditingClient:0
2511  withState:[OCMArg isNotNil]
2512  withTag:@"field2"]);
2513 }
2514 
2515 - (void)testPasswordAutofillHack {
2516  NSDictionary* config = self.mutableTemplateCopy;
2517  [config setValue:@"YES" forKey:@"obscureText"];
2518  [self setClientId:123 configuration:config];
2519 
2520  // Find all the FlutterTextInputViews we created.
2521  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
2522 
2523  FlutterTextInputView* inputView = inputFields[0];
2524 
2525  XCTAssert([inputView isKindOfClass:[UITextField class]]);
2526  // FlutterSecureTextInputView does not respond to font,
2527  // but it should return the default UITextField.font.
2528  XCTAssertNotEqual([inputView performSelector:@selector(font)], nil);
2529 }
2530 
2531 - (void)testClearAutofillContextClearsSelection {
2532  NSMutableDictionary* regularField = self.mutableTemplateCopy;
2533  NSDictionary* editingValue = @{
2534  @"text" : @"REGULAR_TEXT_FIELD",
2535  @"composingBase" : @0,
2536  @"composingExtent" : @3,
2537  @"selectionBase" : @1,
2538  @"selectionExtent" : @4
2539  };
2540  [regularField setValue:@{
2541  @"uniqueIdentifier" : @"field2",
2542  @"hints" : @[ @"hint2" ],
2543  @"editingValue" : editingValue,
2544  }
2545  forKey:@"autofill"];
2546  [regularField addEntriesFromDictionary:editingValue];
2547  [self setClientId:123 configuration:regularField];
2548  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2549  XCTAssertEqual(self.installedInputViews.count, 1ul);
2550 
2551  FlutterTextInputView* oldInputView = self.installedInputViews[0];
2552  XCTAssert([oldInputView.text isEqualToString:@"REGULAR_TEXT_FIELD"]);
2553  FlutterTextRange* selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
2554  XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(1, 3)));
2555 
2556  // Replace the original password field with new one. This should remove
2557  // the old password field, but not immediately.
2558  [self setClientId:124 configuration:self.mutablePasswordTemplateCopy];
2559  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2560 
2561  XCTAssertEqual(self.installedInputViews.count, 2ul);
2562 
2563  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2564  XCTAssertEqual(self.installedInputViews.count, 1ul);
2565 
2566  // Verify the old input view is properly cleaned up.
2567  XCTAssert([oldInputView.text isEqualToString:@""]);
2568  selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
2569  XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(0, 0)));
2570 }
2571 
2572 - (void)testGarbageInputViewsAreNotRemovedImmediately {
2573  // Add a password field that should autofill.
2574  [self setClientId:123 configuration:self.mutablePasswordTemplateCopy];
2575  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2576 
2577  XCTAssertEqual(self.installedInputViews.count, 1ul);
2578  // Add an input field that doesn't autofill. This should remove the password
2579  // field, but not immediately.
2580  [self setClientId:124 configuration:self.mutableTemplateCopy];
2581  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2582 
2583  XCTAssertEqual(self.installedInputViews.count, 2ul);
2584 
2585  [self commitAutofillContextAndVerify];
2586 }
2587 
2588 - (void)testScribbleSetSelectionRects {
2589  NSMutableDictionary* regularField = self.mutableTemplateCopy;
2590  NSDictionary* editingValue = @{
2591  @"text" : @"REGULAR_TEXT_FIELD",
2592  @"composingBase" : @0,
2593  @"composingExtent" : @3,
2594  @"selectionBase" : @1,
2595  @"selectionExtent" : @4
2596  };
2597  [regularField setValue:@{
2598  @"uniqueIdentifier" : @"field1",
2599  @"hints" : @[ @"hint2" ],
2600  @"editingValue" : editingValue,
2601  }
2602  forKey:@"autofill"];
2603  [regularField addEntriesFromDictionary:editingValue];
2604  [self setClientId:123 configuration:regularField];
2605  XCTAssertEqual(self.installedInputViews.count, 1ul);
2606  XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 0u);
2607 
2608  NSArray<NSNumber*>* selectionRect = [NSArray arrayWithObjects:@0, @0, @100, @100, @0, @1, nil];
2609  NSArray* selectionRects = [NSArray arrayWithObjects:selectionRect, nil];
2610  FlutterMethodCall* methodCall =
2611  [FlutterMethodCall methodCallWithMethodName:@"Scribble.setSelectionRects"
2612  arguments:selectionRects];
2613  [textInputPlugin handleMethodCall:methodCall
2614  result:^(id _Nullable result){
2615  }];
2616 
2617  XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 1u);
2618 }
2619 
2620 - (void)testDecommissionedViewAreNotReusedByAutofill {
2621  // Regression test for https://github.com/flutter/flutter/issues/84407.
2622  NSMutableDictionary* configuration = self.mutableTemplateCopy;
2623  [configuration setValue:@{
2624  @"uniqueIdentifier" : @"field1",
2625  @"hints" : @[ UITextContentTypePassword ],
2626  @"editingValue" : @{@"text" : @""}
2627  }
2628  forKey:@"autofill"];
2629  [configuration setValue:@[ [configuration copy] ] forKey:@"fields"];
2630 
2631  [self setClientId:123 configuration:configuration];
2632 
2633  [self setTextInputHide];
2634  UIView* previousActiveView = textInputPlugin.activeView;
2635 
2636  [self setClientId:124 configuration:configuration];
2637 
2638  // Make sure the autofillable view is reused.
2639  XCTAssertEqual(previousActiveView, textInputPlugin.activeView);
2640  XCTAssertNotNil(previousActiveView);
2641  // Does not crash.
2642 }
2643 
2644 - (void)testInitialActiveViewCantAccessTextInputDelegate {
2645  // Before the framework sends the first text input configuration,
2646  // the dummy "activeView" we use should never have access to
2647  // its textInputDelegate.
2648  XCTAssertNil(textInputPlugin.activeView.textInputDelegate);
2649 }
2650 
2651 #pragma mark - Accessibility - Tests
2652 
2653 - (void)testUITextInputAccessibilityNotHiddenWhenShowed {
2654  [self setClientId:123 configuration:self.mutableTemplateCopy];
2655 
2656  // Send show text input method call.
2657  [self setTextInputShow];
2658  // Find all the FlutterTextInputViews we created.
2659  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
2660 
2661  // The input view should not be hidden.
2662  XCTAssertEqual([inputFields count], 1u);
2663 
2664  // Send hide text input method call.
2665  [self setTextInputHide];
2666 
2667  inputFields = self.installedInputViews;
2668 
2669  // The input view should be hidden.
2670  XCTAssertEqual([inputFields count], 0u);
2671 }
2672 
2673 - (void)testFlutterTextInputViewDirectFocusToBackingTextInput {
2674  FlutterTextInputViewSpy* inputView =
2675  [[FlutterTextInputViewSpy alloc] initWithOwner:textInputPlugin];
2676  UIView* container = [[UIView alloc] init];
2677  UIAccessibilityElement* backing =
2678  [[UIAccessibilityElement alloc] initWithAccessibilityContainer:container];
2679  inputView.backingTextInputAccessibilityObject = backing;
2680  // Simulate accessibility focus.
2681  inputView.isAccessibilityFocused = YES;
2682  [inputView accessibilityElementDidBecomeFocused];
2683 
2684  XCTAssertEqual(inputView.receivedNotification, UIAccessibilityScreenChangedNotification);
2685  XCTAssertEqual(inputView.receivedNotificationTarget, backing);
2686 }
2687 
2688 - (void)testFlutterTokenizerCanParseLines {
2689  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2690  id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
2691 
2692  // The tokenizer returns zero range When text is empty.
2693  FlutterTextRange* range = [self getLineRangeFromTokenizer:tokenizer atIndex:0];
2694  XCTAssertEqual(range.range.location, 0u);
2695  XCTAssertEqual(range.range.length, 0u);
2696 
2697  [inputView insertText:@"how are you\nI am fine, Thank you"];
2698 
2699  range = [self getLineRangeFromTokenizer:tokenizer atIndex:0];
2700  XCTAssertEqual(range.range.location, 0u);
2701  XCTAssertEqual(range.range.length, 11u);
2702 
2703  range = [self getLineRangeFromTokenizer:tokenizer atIndex:2];
2704  XCTAssertEqual(range.range.location, 0u);
2705  XCTAssertEqual(range.range.length, 11u);
2706 
2707  range = [self getLineRangeFromTokenizer:tokenizer atIndex:11];
2708  XCTAssertEqual(range.range.location, 0u);
2709  XCTAssertEqual(range.range.length, 11u);
2710 
2711  range = [self getLineRangeFromTokenizer:tokenizer atIndex:12];
2712  XCTAssertEqual(range.range.location, 12u);
2713  XCTAssertEqual(range.range.length, 20u);
2714 
2715  range = [self getLineRangeFromTokenizer:tokenizer atIndex:15];
2716  XCTAssertEqual(range.range.location, 12u);
2717  XCTAssertEqual(range.range.length, 20u);
2718 
2719  range = [self getLineRangeFromTokenizer:tokenizer atIndex:32];
2720  XCTAssertEqual(range.range.location, 12u);
2721  XCTAssertEqual(range.range.length, 20u);
2722 }
2723 
2724 - (void)testFlutterTokenizerLineEnclosingEndOfDocumentInBackwardDirectionShouldNotReturnNil {
2725  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2726  [inputView insertText:@"0123456789\n012345"];
2727  id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
2728 
2729  FlutterTextRange* range =
2730  (FlutterTextRange*)[tokenizer rangeEnclosingPosition:[inputView endOfDocument]
2731  withGranularity:UITextGranularityLine
2732  inDirection:UITextStorageDirectionBackward];
2733  XCTAssertEqual(range.range.location, 11u);
2734  XCTAssertEqual(range.range.length, 6u);
2735 }
2736 
2737 - (void)testFlutterTokenizerLineEnclosingEndOfDocumentInForwardDirectionShouldReturnNilOnIOS17 {
2738  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2739  [inputView insertText:@"0123456789\n012345"];
2740  id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
2741 
2742  FlutterTextRange* range =
2743  (FlutterTextRange*)[tokenizer rangeEnclosingPosition:[inputView endOfDocument]
2744  withGranularity:UITextGranularityLine
2745  inDirection:UITextStorageDirectionForward];
2746  if (@available(iOS 17.0, *)) {
2747  XCTAssertNil(range);
2748  } else {
2749  XCTAssertEqual(range.range.location, 11u);
2750  XCTAssertEqual(range.range.length, 6u);
2751  }
2752 }
2753 
2754 - (void)testFlutterTokenizerLineEnclosingOutOfRangePositionShouldReturnNilOnIOS17 {
2755  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2756  [inputView insertText:@"0123456789\n012345"];
2757  id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
2758 
2760  FlutterTextRange* range =
2761  (FlutterTextRange*)[tokenizer rangeEnclosingPosition:position
2762  withGranularity:UITextGranularityLine
2763  inDirection:UITextStorageDirectionForward];
2764  if (@available(iOS 17.0, *)) {
2765  XCTAssertNil(range);
2766  } else {
2767  XCTAssertEqual(range.range.location, 0u);
2768  XCTAssertEqual(range.range.length, 0u);
2769  }
2770 }
2771 
2772 - (void)testFlutterTextInputPluginRetainsFlutterTextInputView {
2773  FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
2774  FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
2775  myInputPlugin.viewController = flutterViewController;
2776 
2777  __weak UIView* activeView;
2778  @autoreleasepool {
2779  FlutterMethodCall* setClientCall = [FlutterMethodCall
2780  methodCallWithMethodName:@"TextInput.setClient"
2781  arguments:@[
2782  [NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy
2783  ]];
2784  [myInputPlugin handleMethodCall:setClientCall
2785  result:^(id _Nullable result){
2786  }];
2787  activeView = myInputPlugin.textInputView;
2788  FlutterMethodCall* hideCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
2789  arguments:@[]];
2790  [myInputPlugin handleMethodCall:hideCall
2791  result:^(id _Nullable result){
2792  }];
2793  XCTAssertNotNil(activeView);
2794  }
2795  // This assert proves the myInputPlugin.textInputView is not deallocated.
2796  XCTAssertNotNil(activeView);
2797 }
2798 
2799 - (void)testFlutterTextInputPluginHostViewNilCrash {
2800  FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
2801  myInputPlugin.viewController = nil;
2802  XCTAssertThrows([myInputPlugin hostView], @"Throws exception if host view is nil");
2803 }
2804 
2805 - (void)testFlutterTextInputPluginHostViewNotNil {
2806  FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
2807  FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
2808  [flutterEngine runWithEntrypoint:nil];
2809  flutterEngine.viewController = flutterViewController;
2810  XCTAssertNotNil(flutterEngine.textInputPlugin.viewController);
2811  XCTAssertNotNil([flutterEngine.textInputPlugin hostView]);
2812 }
2813 
2814 - (void)testSetPlatformViewClient {
2815  FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
2816  FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
2817  myInputPlugin.viewController = flutterViewController;
2818 
2819  FlutterMethodCall* setClientCall = [FlutterMethodCall
2820  methodCallWithMethodName:@"TextInput.setClient"
2821  arguments:@[ [NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy ]];
2822  [myInputPlugin handleMethodCall:setClientCall
2823  result:^(id _Nullable result){
2824  }];
2825  UIView* activeView = myInputPlugin.textInputView;
2826  XCTAssertNotNil(activeView.superview, @"activeView must be added to the view hierarchy.");
2827  FlutterMethodCall* setPlatformViewClientCall = [FlutterMethodCall
2828  methodCallWithMethodName:@"TextInput.setPlatformViewClient"
2829  arguments:@{@"platformViewId" : [NSNumber numberWithLong:456]}];
2830  [myInputPlugin handleMethodCall:setPlatformViewClientCall
2831  result:^(id _Nullable result){
2832  }];
2833  XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy.");
2834 }
2835 
2836 - (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder {
2837  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2838  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
2839 
2840  [inputView setTextInputClient:123];
2841  [inputView reloadInputViews];
2842  [inputView becomeFirstResponder];
2843  XCTAssert(inputView.isFirstResponder);
2844 
2845  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
2846  [NSNotificationCenter.defaultCenter
2847  postNotificationName:UIKeyboardWillShowNotification
2848  object:nil
2849  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
2850  FlutterMethodCall* onPointerMoveCall =
2851  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2852  arguments:@{@"pointerY" : @(500)}];
2853  [textInputPlugin handleMethodCall:onPointerMoveCall
2854  result:^(id _Nullable result){
2855  }];
2856  XCTAssertFalse(inputView.isFirstResponder);
2857  textInputPlugin.cachedFirstResponder = nil;
2858 }
2859 
2860 - (void)testInteractiveKeyboardAfterUserScrollToTopOfKeyboardWillTakeScreenshot {
2861  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
2862  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
2863  UIScene* scene = scenes.anyObject;
2864  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
2865  UIWindowScene* windowScene = (UIWindowScene*)scene;
2866  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
2867  UIWindow* window = windowScene.windows[0];
2868  [window addSubview:viewController.view];
2869 
2870  [viewController loadView];
2871 
2872  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2873  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
2874 
2875  [inputView setTextInputClient:123];
2876  [inputView reloadInputViews];
2877  [inputView becomeFirstResponder];
2878 
2879  if (textInputPlugin.keyboardView.superview != nil) {
2880  for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
2881  [subView removeFromSuperview];
2882  }
2883  }
2884  XCTAssert(textInputPlugin.keyboardView.superview == nil);
2885  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
2886  [NSNotificationCenter.defaultCenter
2887  postNotificationName:UIKeyboardWillShowNotification
2888  object:nil
2889  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
2890  FlutterMethodCall* onPointerMoveCall =
2891  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2892  arguments:@{@"pointerY" : @(510)}];
2893  [textInputPlugin handleMethodCall:onPointerMoveCall
2894  result:^(id _Nullable result){
2895  }];
2896  XCTAssertFalse(textInputPlugin.keyboardView.superview == nil);
2897  for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
2898  [subView removeFromSuperview];
2899  }
2900  textInputPlugin.cachedFirstResponder = nil;
2901 }
2902 
2903 - (void)testInteractiveKeyboardScreenshotWillBeMovedDownAfterUserScroll {
2904  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
2905  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
2906  UIScene* scene = scenes.anyObject;
2907  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
2908  UIWindowScene* windowScene = (UIWindowScene*)scene;
2909  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
2910  UIWindow* window = windowScene.windows[0];
2911  [window addSubview:viewController.view];
2912 
2913  [viewController loadView];
2914 
2915  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2916  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
2917 
2918  [inputView setTextInputClient:123];
2919  [inputView reloadInputViews];
2920  [inputView becomeFirstResponder];
2921 
2922  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
2923  [NSNotificationCenter.defaultCenter
2924  postNotificationName:UIKeyboardWillShowNotification
2925  object:nil
2926  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
2927  FlutterMethodCall* onPointerMoveCall =
2928  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2929  arguments:@{@"pointerY" : @(510)}];
2930  [textInputPlugin handleMethodCall:onPointerMoveCall
2931  result:^(id _Nullable result){
2932  }];
2933  XCTAssert(textInputPlugin.keyboardView.superview != nil);
2934 
2935  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
2936 
2937  FlutterMethodCall* onPointerMoveCallMove =
2938  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2939  arguments:@{@"pointerY" : @(600)}];
2940  [textInputPlugin handleMethodCall:onPointerMoveCallMove
2941  result:^(id _Nullable result){
2942  }];
2943  XCTAssert(textInputPlugin.keyboardView.superview != nil);
2944 
2945  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0);
2946 
2947  for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
2948  [subView removeFromSuperview];
2949  }
2950  textInputPlugin.cachedFirstResponder = nil;
2951 }
2952 
2953 - (void)testInteractiveKeyboardScreenshotWillBeMovedToOrginalPositionAfterUserScroll {
2954  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
2955  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
2956  UIScene* scene = scenes.anyObject;
2957  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
2958  UIWindowScene* windowScene = (UIWindowScene*)scene;
2959  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
2960  UIWindow* window = windowScene.windows[0];
2961  [window addSubview:viewController.view];
2962 
2963  [viewController loadView];
2964 
2965  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2966  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
2967 
2968  [inputView setTextInputClient:123];
2969  [inputView reloadInputViews];
2970  [inputView becomeFirstResponder];
2971 
2972  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
2973  [NSNotificationCenter.defaultCenter
2974  postNotificationName:UIKeyboardWillShowNotification
2975  object:nil
2976  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
2977  FlutterMethodCall* onPointerMoveCall =
2978  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2979  arguments:@{@"pointerY" : @(500)}];
2980  [textInputPlugin handleMethodCall:onPointerMoveCall
2981  result:^(id _Nullable result){
2982  }];
2983  XCTAssert(textInputPlugin.keyboardView.superview != nil);
2984  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
2985 
2986  FlutterMethodCall* onPointerMoveCallMove =
2987  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2988  arguments:@{@"pointerY" : @(600)}];
2989  [textInputPlugin handleMethodCall:onPointerMoveCallMove
2990  result:^(id _Nullable result){
2991  }];
2992  XCTAssert(textInputPlugin.keyboardView.superview != nil);
2993  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0);
2994 
2995  FlutterMethodCall* onPointerMoveCallBackUp =
2996  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2997  arguments:@{@"pointerY" : @(10)}];
2998  [textInputPlugin handleMethodCall:onPointerMoveCallBackUp
2999  result:^(id _Nullable result){
3000  }];
3001  XCTAssert(textInputPlugin.keyboardView.superview != nil);
3002  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
3003  for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
3004  [subView removeFromSuperview];
3005  }
3006  textInputPlugin.cachedFirstResponder = nil;
3007 }
3008 
3009 - (void)testInteractiveKeyboardFindFirstResponderRecursive {
3010  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3011  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3012  [inputView setTextInputClient:123];
3013  [inputView reloadInputViews];
3014  [inputView becomeFirstResponder];
3015 
3016  UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
3017  XCTAssertEqualObjects(inputView, firstResponder);
3018  textInputPlugin.cachedFirstResponder = nil;
3019 }
3020 
3021 - (void)testInteractiveKeyboardFindFirstResponderRecursiveInMultipleSubviews {
3022  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3023  FlutterTextInputView* subInputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3024  FlutterTextInputView* otherSubInputView =
3025  [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3026  FlutterTextInputView* subFirstResponderInputView =
3027  [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3028  [subInputView addSubview:subFirstResponderInputView];
3029  [inputView addSubview:subInputView];
3030  [inputView addSubview:otherSubInputView];
3031  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3032  [inputView setTextInputClient:123];
3033  [inputView reloadInputViews];
3034  [subInputView setTextInputClient:123];
3035  [subInputView reloadInputViews];
3036  [otherSubInputView setTextInputClient:123];
3037  [otherSubInputView reloadInputViews];
3038  [subFirstResponderInputView setTextInputClient:123];
3039  [subFirstResponderInputView reloadInputViews];
3040  [subFirstResponderInputView becomeFirstResponder];
3041 
3042  UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
3043  XCTAssertEqualObjects(subFirstResponderInputView, firstResponder);
3044  textInputPlugin.cachedFirstResponder = nil;
3045 }
3046 
3047 - (void)testInteractiveKeyboardFindFirstResponderIsNilRecursive {
3048  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3049  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3050  [inputView setTextInputClient:123];
3051  [inputView reloadInputViews];
3052 
3053  UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
3054  XCTAssertNil(firstResponder);
3055  textInputPlugin.cachedFirstResponder = nil;
3056 }
3057 
3058 - (void)testInteractiveKeyboardDidResignFirstResponderDelegateisCalledAfterDismissedKeyboard {
3059  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3060  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3061  UIScene* scene = scenes.anyObject;
3062  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3063  UIWindowScene* windowScene = (UIWindowScene*)scene;
3064  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3065  UIWindow* window = windowScene.windows[0];
3066  [window addSubview:viewController.view];
3067 
3068  [viewController loadView];
3069 
3070  XCTestExpectation* expectation = [[XCTestExpectation alloc]
3071  initWithDescription:
3072  @"didResignFirstResponder is called after screenshot keyboard dismissed."];
3073  OCMStub([engine flutterTextInputView:[OCMArg any] didResignFirstResponderWithTextInputClient:0])
3074  .andDo(^(NSInvocation* invocation) {
3075  [expectation fulfill];
3076  });
3077  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3078  [NSNotificationCenter.defaultCenter
3079  postNotificationName:UIKeyboardWillShowNotification
3080  object:nil
3081  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3082  FlutterMethodCall* initialMoveCall =
3083  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3084  arguments:@{@"pointerY" : @(500)}];
3085  [textInputPlugin handleMethodCall:initialMoveCall
3086  result:^(id _Nullable result){
3087  }];
3088  FlutterMethodCall* subsequentMoveCall =
3089  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3090  arguments:@{@"pointerY" : @(1000)}];
3091  [textInputPlugin handleMethodCall:subsequentMoveCall
3092  result:^(id _Nullable result){
3093  }];
3094 
3095  FlutterMethodCall* pointerUpCall =
3096  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
3097  arguments:@{@"pointerY" : @(1000)}];
3098  [textInputPlugin handleMethodCall:pointerUpCall
3099  result:^(id _Nullable result){
3100  }];
3101 
3102  [self waitForExpectations:@[ expectation ] timeout:2.0];
3103  textInputPlugin.cachedFirstResponder = nil;
3104 }
3105 
3106 - (void)testInteractiveKeyboardScreenshotDismissedAfterPointerLiftedAboveMiddleYOfKeyboard {
3107  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3108  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3109  UIScene* scene = scenes.anyObject;
3110  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3111  UIWindowScene* windowScene = (UIWindowScene*)scene;
3112  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3113  UIWindow* window = windowScene.windows[0];
3114  [window addSubview:viewController.view];
3115 
3116  [viewController loadView];
3117 
3118  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3119  [NSNotificationCenter.defaultCenter
3120  postNotificationName:UIKeyboardWillShowNotification
3121  object:nil
3122  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3123  FlutterMethodCall* initialMoveCall =
3124  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3125  arguments:@{@"pointerY" : @(500)}];
3126  [textInputPlugin handleMethodCall:initialMoveCall
3127  result:^(id _Nullable result){
3128  }];
3129  FlutterMethodCall* subsequentMoveCall =
3130  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3131  arguments:@{@"pointerY" : @(1000)}];
3132  [textInputPlugin handleMethodCall:subsequentMoveCall
3133  result:^(id _Nullable result){
3134  }];
3135 
3136  FlutterMethodCall* subsequentMoveBackUpCall =
3137  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3138  arguments:@{@"pointerY" : @(0)}];
3139  [textInputPlugin handleMethodCall:subsequentMoveBackUpCall
3140  result:^(id _Nullable result){
3141  }];
3142 
3143  FlutterMethodCall* pointerUpCall =
3144  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
3145  arguments:@{@"pointerY" : @(0)}];
3146  [textInputPlugin handleMethodCall:pointerUpCall
3147  result:^(id _Nullable result){
3148  }];
3149  NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
3150  return textInputPlugin.keyboardViewContainer.subviews.count == 0;
3151  }];
3152  XCTNSPredicateExpectation* expectation =
3153  [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
3154  [self waitForExpectations:@[ expectation ] timeout:10.0];
3155  textInputPlugin.cachedFirstResponder = nil;
3156 }
3157 
3158 - (void)testInteractiveKeyboardKeyboardReappearsAfterPointerLiftedAboveMiddleYOfKeyboard {
3159  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3160  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3161  UIScene* scene = scenes.anyObject;
3162  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3163  UIWindowScene* windowScene = (UIWindowScene*)scene;
3164  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3165  UIWindow* window = windowScene.windows[0];
3166  [window addSubview:viewController.view];
3167 
3168  [viewController loadView];
3169 
3170  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3171  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3172 
3173  [inputView setTextInputClient:123];
3174  [inputView reloadInputViews];
3175  [inputView becomeFirstResponder];
3176 
3177  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3178  [NSNotificationCenter.defaultCenter
3179  postNotificationName:UIKeyboardWillShowNotification
3180  object:nil
3181  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3182  FlutterMethodCall* initialMoveCall =
3183  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3184  arguments:@{@"pointerY" : @(500)}];
3185  [textInputPlugin handleMethodCall:initialMoveCall
3186  result:^(id _Nullable result){
3187  }];
3188  FlutterMethodCall* subsequentMoveCall =
3189  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3190  arguments:@{@"pointerY" : @(1000)}];
3191  [textInputPlugin handleMethodCall:subsequentMoveCall
3192  result:^(id _Nullable result){
3193  }];
3194 
3195  FlutterMethodCall* subsequentMoveBackUpCall =
3196  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3197  arguments:@{@"pointerY" : @(0)}];
3198  [textInputPlugin handleMethodCall:subsequentMoveBackUpCall
3199  result:^(id _Nullable result){
3200  }];
3201 
3202  FlutterMethodCall* pointerUpCall =
3203  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
3204  arguments:@{@"pointerY" : @(0)}];
3205  [textInputPlugin handleMethodCall:pointerUpCall
3206  result:^(id _Nullable result){
3207  }];
3208  NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
3209  return textInputPlugin.cachedFirstResponder.isFirstResponder;
3210  }];
3211  XCTNSPredicateExpectation* expectation =
3212  [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
3213  [self waitForExpectations:@[ expectation ] timeout:10.0];
3214  textInputPlugin.cachedFirstResponder = nil;
3215 }
3216 
3217 - (void)testInteractiveKeyboardKeyboardAnimatesToOriginalPositionalOnPointerUp {
3218  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3219  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3220  UIScene* scene = scenes.anyObject;
3221  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3222  UIWindowScene* windowScene = (UIWindowScene*)scene;
3223  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3224  UIWindow* window = windowScene.windows[0];
3225  [window addSubview:viewController.view];
3226 
3227  [viewController loadView];
3228 
3229  XCTestExpectation* expectation =
3230  [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."];
3231  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3232  [NSNotificationCenter.defaultCenter
3233  postNotificationName:UIKeyboardWillShowNotification
3234  object:nil
3235  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3236  FlutterMethodCall* initialMoveCall =
3237  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3238  arguments:@{@"pointerY" : @(500)}];
3239  [textInputPlugin handleMethodCall:initialMoveCall
3240  result:^(id _Nullable result){
3241  }];
3242  FlutterMethodCall* subsequentMoveCall =
3243  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3244  arguments:@{@"pointerY" : @(1000)}];
3245  [textInputPlugin handleMethodCall:subsequentMoveCall
3246  result:^(id _Nullable result){
3247  }];
3248  FlutterMethodCall* upwardVelocityMoveCall =
3249  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3250  arguments:@{@"pointerY" : @(500)}];
3251  [textInputPlugin handleMethodCall:upwardVelocityMoveCall
3252  result:^(id _Nullable result){
3253  }];
3254 
3255  FlutterMethodCall* pointerUpCall =
3256  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
3257  arguments:@{@"pointerY" : @(0)}];
3258  [textInputPlugin
3259  handleMethodCall:pointerUpCall
3260  result:^(id _Nullable result) {
3261  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y,
3262  viewController.flutterScreenIfViewLoaded.bounds.size.height -
3263  keyboardFrame.origin.y);
3264  [expectation fulfill];
3265  }];
3266  textInputPlugin.cachedFirstResponder = nil;
3267 }
3268 
3269 - (void)testInteractiveKeyboardKeyboardAnimatesToDismissalPositionalOnPointerUp {
3270  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3271  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3272  UIScene* scene = scenes.anyObject;
3273  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3274  UIWindowScene* windowScene = (UIWindowScene*)scene;
3275  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3276  UIWindow* window = windowScene.windows[0];
3277  [window addSubview:viewController.view];
3278 
3279  [viewController loadView];
3280 
3281  XCTestExpectation* expectation =
3282  [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."];
3283  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3284  [NSNotificationCenter.defaultCenter
3285  postNotificationName:UIKeyboardWillShowNotification
3286  object:nil
3287  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3288  FlutterMethodCall* initialMoveCall =
3289  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3290  arguments:@{@"pointerY" : @(500)}];
3291  [textInputPlugin handleMethodCall:initialMoveCall
3292  result:^(id _Nullable result){
3293  }];
3294  FlutterMethodCall* subsequentMoveCall =
3295  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3296  arguments:@{@"pointerY" : @(1000)}];
3297  [textInputPlugin handleMethodCall:subsequentMoveCall
3298  result:^(id _Nullable result){
3299  }];
3300 
3301  FlutterMethodCall* pointerUpCall =
3302  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
3303  arguments:@{@"pointerY" : @(1000)}];
3304  [textInputPlugin
3305  handleMethodCall:pointerUpCall
3306  result:^(id _Nullable result) {
3307  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y,
3308  viewController.flutterScreenIfViewLoaded.bounds.size.height);
3309  [expectation fulfill];
3310  }];
3311  textInputPlugin.cachedFirstResponder = nil;
3312 }
3313 - (void)testInteractiveKeyboardShowKeyboardAndRemoveScreenshotAnimationIsNotImmediatelyEnable {
3314  [UIView setAnimationsEnabled:YES];
3315  [textInputPlugin showKeyboardAndRemoveScreenshot];
3316  XCTAssertFalse(
3317  UIView.areAnimationsEnabled,
3318  @"The animation should still be disabled following showKeyboardAndRemoveScreenshot");
3319 }
3320 
3321 - (void)testInteractiveKeyboardShowKeyboardAndRemoveScreenshotAnimationIsReenabledAfterDelay {
3322  [UIView setAnimationsEnabled:YES];
3323  [textInputPlugin showKeyboardAndRemoveScreenshot];
3324 
3325  NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
3326  // This will be enabled after a delay
3327  return UIView.areAnimationsEnabled;
3328  }];
3329  XCTNSPredicateExpectation* expectation =
3330  [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
3331  [self waitForExpectations:@[ expectation ] timeout:10.0];
3332 }
3333 
3334 @end
FlutterTextInputViewSpy
Definition: FlutterTextInputPluginTest.mm:35
caretRectForPosition
CGRect caretRectForPosition
Definition: FlutterTextInputPlugin.h:173
+[FlutterTextPosition positionWithIndex:]
instancetype positionWithIndex:(NSUInteger index)
Definition: FlutterTextInputPlugin.mm:519
selectionRects
NSArray< FlutterTextSelectionRect * > * selectionRects
Definition: FlutterTextInputPlugin.h:160
FlutterEngine
Definition: FlutterEngine.h:61
FlutterSecureTextInputView::textField
UITextField * textField
Definition: FlutterTextInputPlugin.mm:746
FlutterTextInputDelegate-p
Definition: FlutterTextInputDelegate.h:37
+[FlutterMethodCall methodCallWithMethodName:arguments:]
instancetype methodCallWithMethodName:arguments:(NSString *method,[arguments] id _Nullable arguments)
FlutterViewController
Definition: FlutterViewController.h:56
FlutterEngine.h
isScribbleAvailable
BOOL isScribbleAvailable
Definition: FlutterTextInputPlugin.h:162
-[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:327
FlutterTextSelectionRect::rect
CGRect rect
Definition: FlutterTextInputPlugin.h:93
FlutterTextRange
Definition: FlutterTextInputPlugin.h:79
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:2356
-[FlutterTextInputPlugin textInputView]
UIView< UITextInput > * textInputView()
Definition: FlutterTextInputPlugin.mm:2352
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
selectedTextRange
API_AVAILABLE(ios(13.0)) @interface FlutterTextPlaceholder UITextRange * selectedTextRange
Definition: FlutterTextInputPlugin.h:125
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:81
FlutterTextInputPlugin
Definition: FlutterTextInputPlugin.h:33
FlutterTextPosition::affinity
UITextStorageDirection affinity
Definition: FlutterTextInputPlugin.h:70
UIViewController+FlutterScreenAndSceneIfLoaded.h
FlutterTextInputPluginTest
Definition: FlutterTextInputPluginTest.mm:83
FlutterTextSelectionRect::position
NSUInteger position
Definition: FlutterTextInputPlugin.h:94
_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:67
FlutterBinaryMessengerRelay
Definition: FlutterBinaryMessengerRelay.h:14
FlutterJSONMethodCodec
Definition: FlutterCodecs.h:455
FlutterTextInputPlugin::viewController
UIIndirectScribbleInteractionDelegate UIViewController * viewController
Definition: FlutterTextInputPlugin.h:36
FlutterTextPosition::index
NSUInteger index
Definition: FlutterTextInputPlugin.h:69
-[FlutterEngine runWithEntrypoint:initialRoute:]
BOOL runWithEntrypoint:initialRoute:(nullable NSString *entrypoint,[initialRoute] nullable NSString *initialRoute)
FlutterTextSelectionRect
Definition: FlutterTextInputPlugin.h:91
+[FlutterTextSelectionRect selectionRectWithRect:position:writingDirection:]
instancetype selectionRectWithRect:position:writingDirection:(CGRect rect,[position] NSUInteger position,[writingDirection] NSWritingDirection writingDirection)
Definition: FlutterTextInputPlugin.mm:691
FLUTTER_ASSERT_ARC
Definition: VsyncWaiterIosTest.mm:15
markedTextRange
UITextRange * markedTextRange
Definition: FlutterTextInputPlugin.h:136
FlutterTextInputViewSpy::receivedNotificationTarget
id receivedNotificationTarget
Definition: FlutterTextInputPluginTest.mm:37
FlutterViewController.h