Flutter Engine
FlutterTextInputPluginTest.m
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 
5 #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h"
6 
7 #import <OCMock/OCMock.h>
8 #import <XCTest/XCTest.h>
9 
10 #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
11 #import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h"
12 
14 
15 @interface FlutterTextInputView ()
16 @property(nonatomic, copy) NSString* autofillId;
17 
18 - (void)setEditableTransform:(NSArray*)matrix;
19 - (void)setTextInputState:(NSDictionary*)state;
20 - (void)setMarkedRect:(CGRect)markedRect;
21 - (void)updateEditingState;
22 - (BOOL)isVisibleToAutofill;
23 @end
24 
26 @property(nonatomic, strong) UITextField* textField;
27 @end
28 
29 @interface FlutterTextInputPlugin ()
30 @property(nonatomic, strong) FlutterTextInputView* reusableInputView;
31 @property(nonatomic, assign) FlutterTextInputView* activeView;
32 @property(nonatomic, readonly)
33  NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
34 
35 - (void)collectGarbageInputViews;
36 - (UIView*)textInputParentView;
37 @end
38 
39 @interface FlutterTextInputPluginTest : XCTestCase
40 @end
41 
42 @implementation FlutterTextInputPluginTest {
43  NSDictionary* _template;
44  NSDictionary* _passwordTemplate;
45  id engine;
47 }
48 
49 - (void)setUp {
50  [super setUp];
51 
52  engine = OCMClassMock([FlutterEngine class]);
53  textInputPlugin = [[FlutterTextInputPlugin alloc] init];
55 }
56 
57 - (void)tearDown {
58  [engine stopMocking];
59  [[[[textInputPlugin textInputView] superview] subviews]
60  makeObjectsPerformSelector:@selector(removeFromSuperview)];
61 
62  [super tearDown];
63 }
64 
65 - (void)setClientId:(int)clientId configuration:(NSDictionary*)config {
66  FlutterMethodCall* setClientCall =
67  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
68  arguments:@[ [NSNumber numberWithInt:clientId], config ]];
69  [textInputPlugin handleMethodCall:setClientCall
70  result:^(id _Nullable result){
71  }];
72 }
73 
74 - (NSMutableDictionary*)mutableTemplateCopy {
75  if (!_template) {
76  _template = @{
77  @"inputType" : @{@"name" : @"TextInuptType.text"},
78  @"keyboardAppearance" : @"Brightness.light",
79  @"obscureText" : @NO,
80  @"inputAction" : @"TextInputAction.unspecified",
81  @"smartDashesType" : @"0",
82  @"smartQuotesType" : @"0",
83  @"autocorrect" : @YES
84  };
85  }
86 
87  return [_template mutableCopy];
88 }
89 
90 - (NSArray<FlutterTextInputView*>*)installedInputViews {
91  return [textInputPlugin.textInputParentView.subviews
92  filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
93  [FlutterTextInputView class]]];
94 }
95 
96 #pragma mark - Tests
97 
98 - (void)testSecureInput {
99  NSDictionary* config = self.mutableTemplateCopy;
100  [config setValue:@"YES" forKey:@"obscureText"];
101  [self setClientId:123 configuration:config];
102 
103  // Find all the FlutterTextInputViews we created.
104  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
105 
106  // There are no autofill and the mock framework requested a secure entry. The first and only
107  // inserted FlutterTextInputView should be a secure text entry one.
108  FlutterTextInputView* inputView = inputFields[0];
109 
110  // Verify secureTextEntry is set to the correct value.
111  XCTAssertTrue(inputView.secureTextEntry);
112 
113  // Verify keyboardType is set to the default value.
114  XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeDefault);
115 
116  // We should have only ever created one FlutterTextInputView.
117  XCTAssertEqual(inputFields.count, 1);
118 
119  // The one FlutterTextInputView we inserted into the view hierarchy should be the text input
120  // plugin's active text input view.
121  XCTAssertEqual(inputView, textInputPlugin.textInputView);
122 
123  // Despite not given an id in configuration, inputView has
124  // an autofill id.
125  XCTAssert(inputView.autofillId.length > 0);
126 }
127 
128 - (void)testKeyboardType {
129  NSDictionary* config = self.mutableTemplateCopy;
130  [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
131  [self setClientId:123 configuration:config];
132 
133  // Find all the FlutterTextInputViews we created.
134  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
135 
136  FlutterTextInputView* inputView = inputFields[0];
137 
138  // Verify keyboardType is set to the value specified in config.
139  XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeURL);
140 }
141 
142 - (void)testAutocorrectionPromptRectAppears {
143  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithFrame:CGRectZero];
144  inputView.textInputDelegate = engine;
145  [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
146 
147  // Verify behavior.
148  OCMVerify([engine showAutocorrectionPromptRectForStart:0 end:1 withClient:0]);
149 }
150 
151 - (void)testTextRangeFromPositionMatchesUITextViewBehavior {
152  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithFrame:CGRectZero];
153  FlutterTextPosition* fromPosition = [[FlutterTextPosition alloc] initWithIndex:2];
154  FlutterTextPosition* toPosition = [[FlutterTextPosition alloc] initWithIndex:0];
155 
156  FlutterTextRange* flutterRange = (FlutterTextRange*)[inputView textRangeFromPosition:fromPosition
157  toPosition:toPosition];
158  NSRange range = flutterRange.range;
159 
160  XCTAssertEqual(range.location, 0);
161  XCTAssertEqual(range.length, 2);
162 }
163 
164 - (void)testNoZombies {
165  // Regression test for https://github.com/flutter/flutter/issues/62501.
166  FlutterSecureTextInputView* passwordView = [[FlutterSecureTextInputView alloc] init];
167 
168  @autoreleasepool {
169  // Initialize the lazy textField.
170  [passwordView.textField description];
171  }
172  XCTAssert([[passwordView.textField description] containsString:@"TextField"]);
173 }
174 
175 - (void)ensureOnlyActiveViewCanBecomeFirstResponder {
176  for (FlutterTextInputView* inputView in self.installedInputViews) {
177  XCTAssertEqual(inputView.canBecomeFirstResponder, inputView == textInputPlugin.activeView);
178  }
179 }
180 
181 #pragma mark - EditingState tests
182 
183 - (void)testUITextInputCallsUpdateEditingStateOnce {
184  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
185  inputView.textInputDelegate = engine;
186 
187  __block int updateCount = 0;
188  OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]])
189  .andDo(^(NSInvocation* invocation) {
190  updateCount++;
191  });
192 
193  [inputView insertText:@"text to insert"];
194  // Update the framework exactly once.
195  XCTAssertEqual(updateCount, 1);
196 
197  [inputView deleteBackward];
198  XCTAssertEqual(updateCount, 2);
199 
200  inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
201  XCTAssertEqual(updateCount, 3);
202 
203  [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
204  withText:@"replace text"];
205  XCTAssertEqual(updateCount, 4);
206 
207  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
208  XCTAssertEqual(updateCount, 5);
209 
210  [inputView unmarkText];
211  XCTAssertEqual(updateCount, 6);
212 }
213 
214 - (void)testTextChangesDoNotTriggerUpdateEditingClient {
215  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
216  inputView.textInputDelegate = engine;
217 
218  __block int updateCount = 0;
219  OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]])
220  .andDo(^(NSInvocation* invocation) {
221  updateCount++;
222  });
223 
224  [inputView.text setString:@"BEFORE"];
225  XCTAssertEqual(updateCount, 0);
226 
227  inputView.markedTextRange = nil;
228  inputView.selectedTextRange = nil;
229  XCTAssertEqual(updateCount, 1);
230 
231  // Text changes don't trigger an update.
232  XCTAssertEqual(updateCount, 1);
233  [inputView setTextInputState:@{@"text" : @"AFTER"}];
234  XCTAssertEqual(updateCount, 1);
235  [inputView setTextInputState:@{@"text" : @"AFTER"}];
236  XCTAssertEqual(updateCount, 1);
237 
238  // Selection changes don't trigger an update.
239  [inputView
240  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}];
241  XCTAssertEqual(updateCount, 1);
242  [inputView
243  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}];
244  XCTAssertEqual(updateCount, 1);
245 
246  // Composing region changes don't trigger an update.
247  [inputView
248  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}];
249  XCTAssertEqual(updateCount, 1);
250  [inputView
251  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
252  XCTAssertEqual(updateCount, 1);
253 }
254 
255 - (void)testUITextInputAvoidUnnecessaryUndateEditingClientCalls {
256  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
257  inputView.textInputDelegate = engine;
258 
259  __block int updateCount = 0;
260  OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]])
261  .andDo(^(NSInvocation* invocation) {
262  updateCount++;
263  });
264 
265  [inputView unmarkText];
266  // updateEditingClient shouldn't fire as the text is already unmarked.
267  XCTAssertEqual(updateCount, 0);
268 
269  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
270  // updateEditingClient fires in response to setMarkedText.
271  XCTAssertEqual(updateCount, 1);
272 
273  [inputView unmarkText];
274  // updateEditingClient fires in response to unmarkText.
275  XCTAssertEqual(updateCount, 2);
276 }
277 
278 - (void)testUpdateEditingClientNegativeSelection {
279  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
280  inputView.textInputDelegate = engine;
281 
282  [inputView.text setString:@"SELECTION"];
283  inputView.markedTextRange = nil;
284  inputView.selectedTextRange = nil;
285 
286  [inputView setTextInputState:@{
287  @"text" : @"SELECTION",
288  @"selectionBase" : @-1,
289  @"selectionExtent" : @-1
290  }];
291  [inputView updateEditingState];
292  OCMVerify([engine updateEditingClient:0
293  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
294  return ([state[@"selectionBase"] intValue]) == 0 &&
295  ([state[@"selectionExtent"] intValue] == 0);
296  }]]);
297 
298  // Returns (0, 0) when either end goes below 0.
299  [inputView
300  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @-1, @"selectionExtent" : @1}];
301  [inputView updateEditingState];
302  OCMVerify([engine updateEditingClient:0
303  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
304  return ([state[@"selectionBase"] intValue]) == 0 &&
305  ([state[@"selectionExtent"] intValue] == 0);
306  }]]);
307 
308  [inputView
309  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @-1}];
310  [inputView updateEditingState];
311  OCMVerify([engine updateEditingClient:0
312  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
313  return ([state[@"selectionBase"] intValue]) == 0 &&
314  ([state[@"selectionExtent"] intValue] == 0);
315  }]]);
316 }
317 
318 - (void)testUpdateEditingClientSelectionClamping {
319  // Regression test for https://github.com/flutter/flutter/issues/62992.
320  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
321  inputView.textInputDelegate = engine;
322 
323  [inputView.text setString:@"SELECTION"];
324  inputView.markedTextRange = nil;
325  inputView.selectedTextRange = nil;
326 
327  [inputView
328  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @0}];
329  [inputView updateEditingState];
330  OCMVerify([engine updateEditingClient:0
331  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
332  return ([state[@"selectionBase"] intValue]) == 0 &&
333  ([state[@"selectionExtent"] intValue] == 0);
334  }]]);
335 
336  // Needs clamping.
337  [inputView setTextInputState:@{
338  @"text" : @"SELECTION",
339  @"selectionBase" : @0,
340  @"selectionExtent" : @9999
341  }];
342  [inputView updateEditingState];
343 
344  OCMVerify([engine updateEditingClient:0
345  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
346  return ([state[@"selectionBase"] intValue]) == 0 &&
347  ([state[@"selectionExtent"] intValue] == 9);
348  }]]);
349 
350  // No clamping needed, but in reverse direction.
351  [inputView
352  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @0}];
353  [inputView updateEditingState];
354  OCMVerify([engine updateEditingClient:0
355  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
356  return ([state[@"selectionBase"] intValue]) == 0 &&
357  ([state[@"selectionExtent"] intValue] == 1);
358  }]]);
359 
360  // Both ends need clamping.
361  [inputView setTextInputState:@{
362  @"text" : @"SELECTION",
363  @"selectionBase" : @9999,
364  @"selectionExtent" : @9999
365  }];
366  [inputView updateEditingState];
367  OCMVerify([engine updateEditingClient:0
368  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
369  return ([state[@"selectionBase"] intValue]) == 9 &&
370  ([state[@"selectionExtent"] intValue] == 9);
371  }]]);
372 }
373 
374 #pragma mark - UITextInput methods - Tests
375 
376 - (void)testUpdateFirstRectForRange {
377  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
378  [inputView
379  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
380 
381  CGRect kInvalidFirstRect = CGRectMake(-1, -1, 9999, 9999);
382  FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
383  // yOffset = 200.
384  NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];
385  NSArray* zeroMatrix = @[ @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0 ];
386 
387  // Invalid since we don't have the transform or the rect.
388  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
389 
390  [inputView setEditableTransform:yOffsetMatrix];
391  // Invalid since we don't have the rect.
392  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
393 
394  // Valid rect and transform.
395  CGRect testRect = CGRectMake(0, 0, 100, 100);
396  [inputView setMarkedRect:testRect];
397 
398  CGRect finalRect = CGRectOffset(testRect, 0, 200);
399  XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
400  // Idempotent.
401  XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
402 
403  // Use an invalid matrix:
404  [inputView setEditableTransform:zeroMatrix];
405  // Invalid matrix is invalid.
406  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
407  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
408 
409  // Revert the invalid matrix change.
410  [inputView setEditableTransform:yOffsetMatrix];
411  [inputView setMarkedRect:testRect];
412  XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
413 
414  // Use an invalid rect:
415  [inputView setMarkedRect:kInvalidFirstRect];
416  // Invalid marked rect is invalid.
417  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
418  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
419 }
420 
421 #pragma mark - Autofill - Utilities
422 
423 - (NSMutableDictionary*)mutablePasswordTemplateCopy {
424  if (!_passwordTemplate) {
425  _passwordTemplate = @{
426  @"inputType" : @{@"name" : @"TextInuptType.text"},
427  @"keyboardAppearance" : @"Brightness.light",
428  @"obscureText" : @YES,
429  @"inputAction" : @"TextInputAction.unspecified",
430  @"smartDashesType" : @"0",
431  @"smartQuotesType" : @"0",
432  @"autocorrect" : @YES
433  };
434  }
435 
436  return [_passwordTemplate mutableCopy];
437 }
438 
439 - (NSArray<FlutterTextInputView*>*)viewsVisibleToAutofill {
440  return [self.installedInputViews
441  filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisibleToAutofill == YES"]];
442 }
443 
444 - (void)commitAutofillContextAndVerify {
445  FlutterMethodCall* methodCall =
446  [FlutterMethodCall methodCallWithMethodName:@"TextInput.finishAutofillContext"
447  arguments:@YES];
448  [textInputPlugin handleMethodCall:methodCall
449  result:^(id _Nullable result){
450  }];
451 
452  XCTAssertEqual(self.viewsVisibleToAutofill.count,
453  [textInputPlugin.activeView isVisibleToAutofill] ? 1 : 0);
454  XCTAssertNotEqual(textInputPlugin.textInputView, nil);
455  // The active view should still be installed so it doesn't get
456  // deallocated.
457  XCTAssertEqual(self.installedInputViews.count, 1);
458  XCTAssertEqual(textInputPlugin.autofillContext.count, 0);
459 }
460 
461 #pragma mark - Autofill - Tests
462 
463 - (void)testAutofillContext {
464  NSMutableDictionary* field1 = self.mutableTemplateCopy;
465 
466  [field1 setValue:@{
467  @"uniqueIdentifier" : @"field1",
468  @"hints" : @[ @"hint1" ],
469  @"editingValue" : @{@"text" : @""}
470  }
471  forKey:@"autofill"];
472 
473  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
474  [field2 setValue:@{
475  @"uniqueIdentifier" : @"field2",
476  @"hints" : @[ @"hint2" ],
477  @"editingValue" : @{@"text" : @""}
478  }
479  forKey:@"autofill"];
480 
481  NSMutableDictionary* config = [field1 mutableCopy];
482  [config setValue:@[ field1, field2 ] forKey:@"fields"];
483 
484  [self setClientId:123 configuration:config];
485  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
486 
487  XCTAssertEqual(textInputPlugin.autofillContext.count, 2);
488 
489  [textInputPlugin collectGarbageInputViews];
490  XCTAssertEqual(self.installedInputViews.count, 2);
491  XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
492  [self ensureOnlyActiveViewCanBecomeFirstResponder];
493 
494  // The configuration changes.
495  NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy;
496  [field3 setValue:@{
497  @"uniqueIdentifier" : @"field3",
498  @"hints" : @[ @"hint3" ],
499  @"editingValue" : @{@"text" : @""}
500  }
501  forKey:@"autofill"];
502 
503  NSMutableDictionary* oldContext = textInputPlugin.autofillContext;
504  // Replace field2 with field3.
505  [config setValue:@[ field1, field3 ] forKey:@"fields"];
506 
507  [self setClientId:123 configuration:config];
508 
509  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
510  XCTAssertEqual(textInputPlugin.autofillContext.count, 3);
511 
512  [textInputPlugin collectGarbageInputViews];
513  XCTAssertEqual(self.installedInputViews.count, 3);
514  XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
515  [self ensureOnlyActiveViewCanBecomeFirstResponder];
516 
517  // Old autofill input fields are still installed and reused.
518  for (NSString* key in oldContext.allKeys) {
519  XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
520  }
521 
522  // Switch to a password field that has no contentType and is not in an AutofillGroup.
523  config = self.mutablePasswordTemplateCopy;
524 
525  oldContext = textInputPlugin.autofillContext;
526  [self setClientId:124 configuration:config];
527  [self ensureOnlyActiveViewCanBecomeFirstResponder];
528 
529  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1);
530  XCTAssertEqual(textInputPlugin.autofillContext.count, 3);
531 
532  [textInputPlugin collectGarbageInputViews];
533  XCTAssertEqual(self.installedInputViews.count, 4);
534 
535  // Old autofill input fields are still installed and reused.
536  for (NSString* key in oldContext.allKeys) {
537  XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
538  }
539  // The active view should change.
540  XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
541  [self ensureOnlyActiveViewCanBecomeFirstResponder];
542 
543  // Switch to a similar password field, the previous field should be reused.
544  oldContext = textInputPlugin.autofillContext;
545  [self setClientId:200 configuration:config];
546 
547  // Reuse the input view instance from the last time.
548  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1);
549  XCTAssertEqual(textInputPlugin.autofillContext.count, 3);
550 
551  [textInputPlugin collectGarbageInputViews];
552  XCTAssertEqual(self.installedInputViews.count, 4);
553 
554  // Old autofill input fields are still installed and reused.
555  for (NSString* key in oldContext.allKeys) {
556  XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
557  }
558  XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
559  [self ensureOnlyActiveViewCanBecomeFirstResponder];
560 }
561 
562 - (void)testCommitAutofillContext {
563  NSMutableDictionary* field1 = self.mutableTemplateCopy;
564  [field1 setValue:@{
565  @"uniqueIdentifier" : @"field1",
566  @"hints" : @[ @"hint1" ],
567  @"editingValue" : @{@"text" : @""}
568  }
569  forKey:@"autofill"];
570 
571  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
572  [field2 setValue:@{
573  @"uniqueIdentifier" : @"field2",
574  @"hints" : @[ @"hint2" ],
575  @"editingValue" : @{@"text" : @""}
576  }
577  forKey:@"autofill"];
578 
579  NSMutableDictionary* field3 = self.mutableTemplateCopy;
580  [field3 setValue:@{
581  @"uniqueIdentifier" : @"field3",
582  @"hints" : @[ @"hint3" ],
583  @"editingValue" : @{@"text" : @""}
584  }
585  forKey:@"autofill"];
586 
587  NSMutableDictionary* config = [field1 mutableCopy];
588  [config setValue:@[ field1, field2 ] forKey:@"fields"];
589 
590  [self setClientId:123 configuration:config];
591  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
592  XCTAssertEqual(textInputPlugin.autofillContext.count, 2);
593  [self ensureOnlyActiveViewCanBecomeFirstResponder];
594 
595  [self commitAutofillContextAndVerify];
596  XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
597  [self ensureOnlyActiveViewCanBecomeFirstResponder];
598 
599  // Install the password field again.
600  [self setClientId:123 configuration:config];
601  // Switch to a regular autofill group.
602  [self setClientId:124 configuration:field3];
603  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1);
604 
605  [textInputPlugin collectGarbageInputViews];
606  XCTAssertEqual(self.installedInputViews.count, 3);
607  XCTAssertEqual(textInputPlugin.autofillContext.count, 2);
608  XCTAssertNotEqual(textInputPlugin.textInputView, nil);
609  [self ensureOnlyActiveViewCanBecomeFirstResponder];
610 
611  [self commitAutofillContextAndVerify];
612  XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
613  [self ensureOnlyActiveViewCanBecomeFirstResponder];
614 
615  // Now switch to an input field that does not autofill.
616  [self setClientId:125 configuration:self.mutableTemplateCopy];
617 
618  XCTAssertEqual(self.viewsVisibleToAutofill.count, 0);
619  XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
620  // The active view should still be installed so it doesn't get
621  // deallocated.
622 
623  [textInputPlugin collectGarbageInputViews];
624  XCTAssertEqual(self.installedInputViews.count, 1);
625  XCTAssertEqual(textInputPlugin.autofillContext.count, 0);
626  [self ensureOnlyActiveViewCanBecomeFirstResponder];
627 
628  [self commitAutofillContextAndVerify];
629  XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
630  [self ensureOnlyActiveViewCanBecomeFirstResponder];
631 }
632 
633 - (void)testAutofillInputViews {
634  NSMutableDictionary* field1 = self.mutableTemplateCopy;
635  [field1 setValue:@{
636  @"uniqueIdentifier" : @"field1",
637  @"hints" : @[ @"hint1" ],
638  @"editingValue" : @{@"text" : @""}
639  }
640  forKey:@"autofill"];
641 
642  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
643  [field2 setValue:@{
644  @"uniqueIdentifier" : @"field2",
645  @"hints" : @[ @"hint2" ],
646  @"editingValue" : @{@"text" : @""}
647  }
648  forKey:@"autofill"];
649 
650  NSMutableDictionary* config = [field1 mutableCopy];
651  [config setValue:@[ field1, field2 ] forKey:@"fields"];
652 
653  [self setClientId:123 configuration:config];
654  [self ensureOnlyActiveViewCanBecomeFirstResponder];
655 
656  // Find all the FlutterTextInputViews we created.
657  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
658 
659  // Both fields are installed and visible because it's a password group.
660  XCTAssertEqual(inputFields.count, 2);
661  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
662 
663  // Find the inactive autofillable input field.
664  FlutterTextInputView* inactiveView = inputFields[1];
665  [inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)]
666  withText:@"Autofilled!"];
667  [self ensureOnlyActiveViewCanBecomeFirstResponder];
668 
669  // Verify behavior.
670  OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]);
671 }
672 
673 - (void)testPasswordAutofillHack {
674  NSDictionary* config = self.mutableTemplateCopy;
675  [config setValue:@"YES" forKey:@"obscureText"];
676  [self setClientId:123 configuration:config];
677 
678  // Find all the FlutterTextInputViews we created.
679  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
680 
681  FlutterTextInputView* inputView = inputFields[0];
682 
683  XCTAssert([inputView isKindOfClass:[UITextField class]]);
684  // FlutterSecureTextInputView does not respond to font,
685  // but it should return the default UITextField.font.
686  XCTAssertNotEqual([inputView performSelector:@selector(font)], nil);
687 }
688 
689 - (void)testClearAutofillContextClearsSelection {
690  NSMutableDictionary* regularField = self.mutableTemplateCopy;
691  NSDictionary* editingValue = @{
692  @"text" : @"REGULAR_TEXT_FIELD",
693  @"composingBase" : @0,
694  @"composingExtent" : @3,
695  @"selectionBase" : @1,
696  @"selectionExtent" : @4
697  };
698  [regularField setValue:@{
699  @"uniqueIdentifier" : @"field2",
700  @"hints" : @[ @"hint2" ],
701  @"editingValue" : editingValue,
702  }
703  forKey:@"autofill"];
704  [regularField addEntriesFromDictionary:editingValue];
705  [self setClientId:123 configuration:regularField];
706  [self ensureOnlyActiveViewCanBecomeFirstResponder];
707  XCTAssertEqual(self.installedInputViews.count, 1);
708 
709  FlutterTextInputView* oldInputView = self.installedInputViews[0];
710  XCTAssert([oldInputView.text isEqualToString:@"REGULAR_TEXT_FIELD"]);
711  FlutterTextRange* selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
712  XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(1, 3)));
713 
714  // Replace the original password field with new one. This should remove
715  // the old password field, but not immediately.
716  [self setClientId:124 configuration:self.mutablePasswordTemplateCopy];
717  [self ensureOnlyActiveViewCanBecomeFirstResponder];
718 
719  XCTAssertEqual(self.installedInputViews.count, 2);
720 
721  [textInputPlugin collectGarbageInputViews];
722  XCTAssertEqual(self.installedInputViews.count, 1);
723 
724  // Verify the old input view is properly cleaned up.
725  XCTAssert([oldInputView.text isEqualToString:@""]);
726  selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
727  XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(0, 0)));
728 }
729 
730 - (void)testGarbageInputViewsAreNotRemovedImmediately {
731  // Add a password field that should autofill.
732  [self setClientId:123 configuration:self.mutablePasswordTemplateCopy];
733  [self ensureOnlyActiveViewCanBecomeFirstResponder];
734 
735  XCTAssertEqual(self.installedInputViews.count, 1);
736  // Add an input field that doesn't autofill. This should remove the password
737  // field, but not immediately.
738  [self setClientId:124 configuration:self.mutableTemplateCopy];
739  [self ensureOnlyActiveViewCanBecomeFirstResponder];
740 
741  XCTAssertEqual(self.installedInputViews.count, 2);
742 
743  [self commitAutofillContextAndVerify];
744 }
745 
746 @end
FlutterTextInputPlugin * textInputPlugin
NSDictionary * _passwordTemplate
id< FlutterTextInputDelegate > textInputDelegate
instancetype rangeWithNSRange:(NSRange range)
UIView< UITextInput > * textInputView()
const CGRect kInvalidFirstRect
instancetype methodCallWithMethodName:arguments:(NSString *method, [arguments] id _Nullable arguments)
id< FlutterTextInputDelegate > textInputDelegate
#define FLUTTER_ASSERT_ARC
Definition: FlutterMacros.h:44