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