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