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