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