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