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