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