| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" |
| |
| #import <OCMock/OCMock.h> |
| #import <XCTest/XCTest.h> |
| |
| #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h" |
| |
| FLUTTER_ASSERT_ARC |
| |
| @interface FlutterTextInputView () |
| @property(nonatomic, copy) NSString* autofillId; |
| |
| - (void)setEditableTransform:(NSArray*)matrix; |
| - (void)setTextInputState:(NSDictionary*)state; |
| - (void)setMarkedRect:(CGRect)markedRect; |
| - (void)updateEditingState; |
| - (BOOL)isVisibleToAutofill; |
| |
| @end |
| |
| @interface FlutterTextInputViewSpy : FlutterTextInputView |
| @property(nonatomic, assign) UIAccessibilityNotifications receivedNotification; |
| @property(nonatomic, assign) id receivedNotificationTarget; |
| @property(nonatomic, assign) BOOL isAccessibilityFocused; |
| |
| - (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target; |
| |
| @end |
| |
| @implementation FlutterTextInputViewSpy { |
| } |
| |
| - (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target { |
| self.receivedNotification = notification; |
| self.receivedNotificationTarget = target; |
| } |
| |
| - (BOOL)accessibilityElementIsFocused { |
| return _isAccessibilityFocused; |
| } |
| |
| @end |
| |
| @interface FlutterSecureTextInputView : FlutterTextInputView |
| @property(nonatomic, strong) UITextField* textField; |
| @end |
| |
| @interface FlutterTextInputPlugin () |
| @property(nonatomic, strong) FlutterTextInputView* reusableInputView; |
| @property(nonatomic, assign) FlutterTextInputView* activeView; |
| @property(nonatomic, readonly) |
| NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext; |
| |
| - (void)collectGarbageInputViews; |
| - (NSArray<UIView*>*)textInputViews; |
| @end |
| |
| @interface FlutterTextInputPluginTest : XCTestCase |
| @end |
| |
| @implementation FlutterTextInputPluginTest { |
| NSDictionary* _template; |
| NSDictionary* _passwordTemplate; |
| id engine; |
| FlutterTextInputPlugin* textInputPlugin; |
| } |
| |
| - (void)setUp { |
| [super setUp]; |
| |
| engine = OCMClassMock([FlutterEngine class]); |
| textInputPlugin = [[FlutterTextInputPlugin alloc] init]; |
| textInputPlugin.textInputDelegate = engine; |
| } |
| |
| - (void)tearDown { |
| [engine stopMocking]; |
| [[[[textInputPlugin textInputView] superview] subviews] |
| makeObjectsPerformSelector:@selector(removeFromSuperview)]; |
| |
| [super tearDown]; |
| } |
| |
| - (void)setClientId:(int)clientId configuration:(NSDictionary*)config { |
| FlutterMethodCall* setClientCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" |
| arguments:@[ [NSNumber numberWithInt:clientId], config ]]; |
| [textInputPlugin handleMethodCall:setClientCall |
| result:^(id _Nullable result){ |
| }]; |
| } |
| |
| - (void)setTextInputShow { |
| FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show" |
| arguments:@[]]; |
| [textInputPlugin handleMethodCall:setClientCall |
| result:^(id _Nullable result){ |
| }]; |
| } |
| |
| - (void)setTextInputHide { |
| FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide" |
| arguments:@[]]; |
| [textInputPlugin handleMethodCall:setClientCall |
| result:^(id _Nullable result){ |
| }]; |
| } |
| |
| - (NSMutableDictionary*)mutableTemplateCopy { |
| if (!_template) { |
| _template = @{ |
| @"inputType" : @{@"name" : @"TextInuptType.text"}, |
| @"keyboardAppearance" : @"Brightness.light", |
| @"obscureText" : @NO, |
| @"inputAction" : @"TextInputAction.unspecified", |
| @"smartDashesType" : @"0", |
| @"smartQuotesType" : @"0", |
| @"autocorrect" : @YES |
| }; |
| } |
| |
| return [_template mutableCopy]; |
| } |
| |
| - (NSArray<FlutterTextInputView*>*)installedInputViews { |
| return (NSArray<FlutterTextInputView*>*)[textInputPlugin.textInputViews |
| filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@", |
| [FlutterTextInputView class]]]; |
| } |
| |
| #pragma mark - Tests |
| |
| - (void)testSecureInput { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@"YES" forKey:@"obscureText"]; |
| [self setClientId:123 configuration:config]; |
| |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| // There are no autofill and the mock framework requested a secure entry. The first and only |
| // inserted FlutterTextInputView should be a secure text entry one. |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| // Verify secureTextEntry is set to the correct value. |
| XCTAssertTrue(inputView.secureTextEntry); |
| |
| // Verify keyboardType is set to the default value. |
| XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeDefault); |
| |
| // We should have only ever created one FlutterTextInputView. |
| XCTAssertEqual(inputFields.count, 1); |
| |
| // The one FlutterTextInputView we inserted into the view hierarchy should be the text input |
| // plugin's active text input view. |
| XCTAssertEqual(inputView, textInputPlugin.textInputView); |
| |
| // Despite not given an id in configuration, inputView has |
| // an autofill id. |
| XCTAssert(inputView.autofillId.length > 0); |
| } |
| |
| - (void)testKeyboardType { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"]; |
| [self setClientId:123 configuration:config]; |
| |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| // Verify keyboardType is set to the value specified in config. |
| XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeURL); |
| } |
| |
| - (void)testAutocorrectionPromptRectAppears { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithFrame:CGRectZero]; |
| inputView.textInputDelegate = engine; |
| [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; |
| |
| // Verify behavior. |
| OCMVerify([engine showAutocorrectionPromptRectForStart:0 end:1 withClient:0]); |
| } |
| |
| - (void)testTextRangeFromPositionMatchesUITextViewBehavior { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithFrame:CGRectZero]; |
| FlutterTextPosition* fromPosition = [[FlutterTextPosition alloc] initWithIndex:2]; |
| FlutterTextPosition* toPosition = [[FlutterTextPosition alloc] initWithIndex:0]; |
| |
| FlutterTextRange* flutterRange = (FlutterTextRange*)[inputView textRangeFromPosition:fromPosition |
| toPosition:toPosition]; |
| NSRange range = flutterRange.range; |
| |
| XCTAssertEqual(range.location, 0); |
| XCTAssertEqual(range.length, 2); |
| } |
| |
| - (void)testNoZombies { |
| // Regression test for https://github.com/flutter/flutter/issues/62501. |
| FlutterSecureTextInputView* passwordView = [[FlutterSecureTextInputView alloc] init]; |
| |
| @autoreleasepool { |
| // Initialize the lazy textField. |
| [passwordView.textField description]; |
| } |
| XCTAssert([[passwordView.textField description] containsString:@"TextField"]); |
| } |
| |
| - (void)ensureOnlyActiveViewCanBecomeFirstResponder { |
| for (FlutterTextInputView* inputView in self.installedInputViews) { |
| XCTAssertEqual(inputView.canBecomeFirstResponder, inputView == textInputPlugin.activeView); |
| } |
| } |
| |
| #pragma mark - EditingState tests |
| |
| - (void)testUITextInputCallsUpdateEditingStateOnce { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; |
| inputView.textInputDelegate = engine; |
| |
| __block int updateCount = 0; |
| OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView insertText:@"text to insert"]; |
| // Update the framework exactly once. |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView deleteBackward]; |
| XCTAssertEqual(updateCount, 2); |
| |
| inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]; |
| XCTAssertEqual(updateCount, 3); |
| |
| [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] |
| withText:@"replace text"]; |
| XCTAssertEqual(updateCount, 4); |
| |
| [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; |
| XCTAssertEqual(updateCount, 5); |
| |
| [inputView unmarkText]; |
| XCTAssertEqual(updateCount, 6); |
| } |
| |
| - (void)testTextChangesDoNotTriggerUpdateEditingClient { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; |
| inputView.textInputDelegate = engine; |
| |
| __block int updateCount = 0; |
| OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView.text setString:@"BEFORE"]; |
| XCTAssertEqual(updateCount, 0); |
| |
| inputView.markedTextRange = nil; |
| inputView.selectedTextRange = nil; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Text changes don't trigger an update. |
| XCTAssertEqual(updateCount, 1); |
| [inputView setTextInputState:@{@"text" : @"AFTER"}]; |
| XCTAssertEqual(updateCount, 1); |
| [inputView setTextInputState:@{@"text" : @"AFTER"}]; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Selection changes don't trigger an update. |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}]; |
| XCTAssertEqual(updateCount, 1); |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}]; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Composing region changes don't trigger an update. |
| [inputView |
| setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}]; |
| XCTAssertEqual(updateCount, 1); |
| [inputView |
| setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}]; |
| XCTAssertEqual(updateCount, 1); |
| } |
| |
| - (void)testUITextInputAvoidUnnecessaryUndateEditingClientCalls { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; |
| inputView.textInputDelegate = engine; |
| |
| __block int updateCount = 0; |
| OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView unmarkText]; |
| // updateEditingClient shouldn't fire as the text is already unmarked. |
| XCTAssertEqual(updateCount, 0); |
| |
| [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; |
| // updateEditingClient fires in response to setMarkedText. |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView unmarkText]; |
| // updateEditingClient fires in response to unmarkText. |
| XCTAssertEqual(updateCount, 2); |
| } |
| |
| - (void)testUpdateEditingClientNegativeSelection { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; |
| inputView.textInputDelegate = engine; |
| |
| [inputView.text setString:@"SELECTION"]; |
| inputView.markedTextRange = nil; |
| inputView.selectedTextRange = nil; |
| |
| [inputView setTextInputState:@{ |
| @"text" : @"SELECTION", |
| @"selectionBase" : @-1, |
| @"selectionExtent" : @-1 |
| }]; |
| [inputView updateEditingState]; |
| OCMVerify([engine updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 0); |
| }]]); |
| |
| // Returns (0, 0) when either end goes below 0. |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @-1, @"selectionExtent" : @1}]; |
| [inputView updateEditingState]; |
| OCMVerify([engine updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 0); |
| }]]); |
| |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @-1}]; |
| [inputView updateEditingState]; |
| OCMVerify([engine updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 0); |
| }]]); |
| } |
| |
| - (void)testUpdateEditingClientSelectionClamping { |
| // Regression test for https://github.com/flutter/flutter/issues/62992. |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; |
| inputView.textInputDelegate = engine; |
| |
| [inputView.text setString:@"SELECTION"]; |
| inputView.markedTextRange = nil; |
| inputView.selectedTextRange = nil; |
| |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @0}]; |
| [inputView updateEditingState]; |
| OCMVerify([engine updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 0); |
| }]]); |
| |
| // Needs clamping. |
| [inputView setTextInputState:@{ |
| @"text" : @"SELECTION", |
| @"selectionBase" : @0, |
| @"selectionExtent" : @9999 |
| }]; |
| [inputView updateEditingState]; |
| |
| OCMVerify([engine updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 9); |
| }]]); |
| |
| // No clamping needed, but in reverse direction. |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @0}]; |
| [inputView updateEditingState]; |
| OCMVerify([engine updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 1); |
| }]]); |
| |
| // Both ends need clamping. |
| [inputView setTextInputState:@{ |
| @"text" : @"SELECTION", |
| @"selectionBase" : @9999, |
| @"selectionExtent" : @9999 |
| }]; |
| [inputView updateEditingState]; |
| OCMVerify([engine updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 9 && |
| ([state[@"selectionExtent"] intValue] == 9); |
| }]]); |
| } |
| |
| #pragma mark - UITextInput methods - Tests |
| |
| - (void)testUpdateFirstRectForRange { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; |
| [inputView |
| setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}]; |
| |
| CGRect kInvalidFirstRect = CGRectMake(-1, -1, 9999, 9999); |
| FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]; |
| // yOffset = 200. |
| NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ]; |
| NSArray* zeroMatrix = @[ @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0 ]; |
| |
| // Invalid since we don't have the transform or the rect. |
| XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); |
| |
| [inputView setEditableTransform:yOffsetMatrix]; |
| // Invalid since we don't have the rect. |
| XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); |
| |
| // Valid rect and transform. |
| CGRect testRect = CGRectMake(0, 0, 100, 100); |
| [inputView setMarkedRect:testRect]; |
| |
| CGRect finalRect = CGRectOffset(testRect, 0, 200); |
| XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range])); |
| // Idempotent. |
| XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range])); |
| |
| // Use an invalid matrix: |
| [inputView setEditableTransform:zeroMatrix]; |
| // Invalid matrix is invalid. |
| XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); |
| XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); |
| |
| // Revert the invalid matrix change. |
| [inputView setEditableTransform:yOffsetMatrix]; |
| [inputView setMarkedRect:testRect]; |
| XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range])); |
| |
| // Use an invalid rect: |
| [inputView setMarkedRect:kInvalidFirstRect]; |
| // Invalid marked rect is invalid. |
| XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); |
| XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); |
| } |
| |
| #pragma mark - Autofill - Utilities |
| |
| - (NSMutableDictionary*)mutablePasswordTemplateCopy { |
| if (!_passwordTemplate) { |
| _passwordTemplate = @{ |
| @"inputType" : @{@"name" : @"TextInuptType.text"}, |
| @"keyboardAppearance" : @"Brightness.light", |
| @"obscureText" : @YES, |
| @"inputAction" : @"TextInputAction.unspecified", |
| @"smartDashesType" : @"0", |
| @"smartQuotesType" : @"0", |
| @"autocorrect" : @YES |
| }; |
| } |
| |
| return [_passwordTemplate mutableCopy]; |
| } |
| |
| - (NSArray<FlutterTextInputView*>*)viewsVisibleToAutofill { |
| return [self.installedInputViews |
| filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisibleToAutofill == YES"]]; |
| } |
| |
| - (void)commitAutofillContextAndVerify { |
| FlutterMethodCall* methodCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.finishAutofillContext" |
| arguments:@YES]; |
| [textInputPlugin handleMethodCall:methodCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, |
| [textInputPlugin.activeView isVisibleToAutofill] ? 1 : 0); |
| XCTAssertNotEqual(textInputPlugin.textInputView, nil); |
| // The active view should still be installed so it doesn't get |
| // deallocated. |
| XCTAssertEqual(self.installedInputViews.count, 1); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 0); |
| } |
| |
| #pragma mark - Autofill - Tests |
| |
| - (void)testAutofillContext { |
| NSMutableDictionary* field1 = self.mutableTemplateCopy; |
| |
| [field1 setValue:@{ |
| @"uniqueIdentifier" : @"field1", |
| @"hints" : @[ @"hint1" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy; |
| [field2 setValue:@{ |
| @"uniqueIdentifier" : @"field2", |
| @"hints" : @[ @"hint2" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* config = [field1 mutableCopy]; |
| [config setValue:@[ field1, field2 ] forKey:@"fields"]; |
| |
| [self setClientId:123 configuration:config]; |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 2); |
| |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 2); |
| |
| [textInputPlugin collectGarbageInputViews]; |
| XCTAssertEqual(self.installedInputViews.count, 2); |
| XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // The configuration changes. |
| NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy; |
| [field3 setValue:@{ |
| @"uniqueIdentifier" : @"field3", |
| @"hints" : @[ @"hint3" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* oldContext = textInputPlugin.autofillContext; |
| // Replace field2 with field3. |
| [config setValue:@[ field1, field3 ] forKey:@"fields"]; |
| |
| [self setClientId:123 configuration:config]; |
| |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 2); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 3); |
| |
| [textInputPlugin collectGarbageInputViews]; |
| XCTAssertEqual(self.installedInputViews.count, 3); |
| XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Old autofill input fields are still installed and reused. |
| for (NSString* key in oldContext.allKeys) { |
| XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]); |
| } |
| |
| // Switch to a password field that has no contentType and is not in an AutofillGroup. |
| config = self.mutablePasswordTemplateCopy; |
| |
| oldContext = textInputPlugin.autofillContext; |
| [self setClientId:124 configuration:config]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 1); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 3); |
| |
| [textInputPlugin collectGarbageInputViews]; |
| XCTAssertEqual(self.installedInputViews.count, 4); |
| |
| // Old autofill input fields are still installed and reused. |
| for (NSString* key in oldContext.allKeys) { |
| XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]); |
| } |
| // The active view should change. |
| XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Switch to a similar password field, the previous field should be reused. |
| oldContext = textInputPlugin.autofillContext; |
| [self setClientId:200 configuration:config]; |
| |
| // Reuse the input view instance from the last time. |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 1); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 3); |
| |
| [textInputPlugin collectGarbageInputViews]; |
| XCTAssertEqual(self.installedInputViews.count, 4); |
| |
| // Old autofill input fields are still installed and reused. |
| for (NSString* key in oldContext.allKeys) { |
| XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]); |
| } |
| XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| } |
| |
| - (void)testCommitAutofillContext { |
| NSMutableDictionary* field1 = self.mutableTemplateCopy; |
| [field1 setValue:@{ |
| @"uniqueIdentifier" : @"field1", |
| @"hints" : @[ @"hint1" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy; |
| [field2 setValue:@{ |
| @"uniqueIdentifier" : @"field2", |
| @"hints" : @[ @"hint2" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* field3 = self.mutableTemplateCopy; |
| [field3 setValue:@{ |
| @"uniqueIdentifier" : @"field3", |
| @"hints" : @[ @"hint3" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* config = [field1 mutableCopy]; |
| [config setValue:@[ field1, field2 ] forKey:@"fields"]; |
| |
| [self setClientId:123 configuration:config]; |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 2); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 2); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| [self commitAutofillContextAndVerify]; |
| XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Install the password field again. |
| [self setClientId:123 configuration:config]; |
| // Switch to a regular autofill group. |
| [self setClientId:124 configuration:field3]; |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 1); |
| |
| [textInputPlugin collectGarbageInputViews]; |
| XCTAssertEqual(self.installedInputViews.count, 3); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 2); |
| XCTAssertNotEqual(textInputPlugin.textInputView, nil); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| [self commitAutofillContextAndVerify]; |
| XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Now switch to an input field that does not autofill. |
| [self setClientId:125 configuration:self.mutableTemplateCopy]; |
| |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 0); |
| XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); |
| // The active view should still be installed so it doesn't get |
| // deallocated. |
| |
| [textInputPlugin collectGarbageInputViews]; |
| XCTAssertEqual(self.installedInputViews.count, 1); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 0); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| [self commitAutofillContextAndVerify]; |
| XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| } |
| |
| - (void)testAutofillInputViews { |
| NSMutableDictionary* field1 = self.mutableTemplateCopy; |
| [field1 setValue:@{ |
| @"uniqueIdentifier" : @"field1", |
| @"hints" : @[ @"hint1" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy; |
| [field2 setValue:@{ |
| @"uniqueIdentifier" : @"field2", |
| @"hints" : @[ @"hint2" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* config = [field1 mutableCopy]; |
| [config setValue:@[ field1, field2 ] forKey:@"fields"]; |
| |
| [self setClientId:123 configuration:config]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| // Both fields are installed and visible because it's a password group. |
| XCTAssertEqual(inputFields.count, 2); |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 2); |
| |
| // Find the inactive autofillable input field. |
| FlutterTextInputView* inactiveView = inputFields[1]; |
| [inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)] |
| withText:@"Autofilled!"]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Verify behavior. |
| OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]); |
| } |
| |
| - (void)testPasswordAutofillHack { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@"YES" forKey:@"obscureText"]; |
| [self setClientId:123 configuration:config]; |
| |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| XCTAssert([inputView isKindOfClass:[UITextField class]]); |
| // FlutterSecureTextInputView does not respond to font, |
| // but it should return the default UITextField.font. |
| XCTAssertNotEqual([inputView performSelector:@selector(font)], nil); |
| } |
| |
| - (void)testClearAutofillContextClearsSelection { |
| NSMutableDictionary* regularField = self.mutableTemplateCopy; |
| NSDictionary* editingValue = @{ |
| @"text" : @"REGULAR_TEXT_FIELD", |
| @"composingBase" : @0, |
| @"composingExtent" : @3, |
| @"selectionBase" : @1, |
| @"selectionExtent" : @4 |
| }; |
| [regularField setValue:@{ |
| @"uniqueIdentifier" : @"field2", |
| @"hints" : @[ @"hint2" ], |
| @"editingValue" : editingValue, |
| } |
| forKey:@"autofill"]; |
| [regularField addEntriesFromDictionary:editingValue]; |
| [self setClientId:123 configuration:regularField]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| XCTAssertEqual(self.installedInputViews.count, 1); |
| |
| FlutterTextInputView* oldInputView = self.installedInputViews[0]; |
| XCTAssert([oldInputView.text isEqualToString:@"REGULAR_TEXT_FIELD"]); |
| FlutterTextRange* selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange; |
| XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(1, 3))); |
| |
| // Replace the original password field with new one. This should remove |
| // the old password field, but not immediately. |
| [self setClientId:124 configuration:self.mutablePasswordTemplateCopy]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| XCTAssertEqual(self.installedInputViews.count, 2); |
| |
| [textInputPlugin collectGarbageInputViews]; |
| XCTAssertEqual(self.installedInputViews.count, 1); |
| |
| // Verify the old input view is properly cleaned up. |
| XCTAssert([oldInputView.text isEqualToString:@""]); |
| selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange; |
| XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(0, 0))); |
| } |
| |
| - (void)testGarbageInputViewsAreNotRemovedImmediately { |
| // Add a password field that should autofill. |
| [self setClientId:123 configuration:self.mutablePasswordTemplateCopy]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| XCTAssertEqual(self.installedInputViews.count, 1); |
| // Add an input field that doesn't autofill. This should remove the password |
| // field, but not immediately. |
| [self setClientId:124 configuration:self.mutableTemplateCopy]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| XCTAssertEqual(self.installedInputViews.count, 2); |
| |
| [self commitAutofillContextAndVerify]; |
| } |
| |
| #pragma mark - Accessibility - Tests |
| |
| - (void)testUITextInputAccessibilityNotHiddenWhenShowed { |
| // Send show text input method call. |
| [self setTextInputShow]; |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| // The input view should not be hidden. |
| XCTAssertEqual([inputFields count], 1u); |
| |
| // Send hide text input method call. |
| [self setTextInputHide]; |
| |
| inputFields = self.installedInputViews; |
| |
| // The input view should be hidden. |
| XCTAssertEqual([inputFields count], 0u); |
| } |
| |
| - (void)testFlutterTextInputViewDirectFocusToBackingTextInput { |
| FlutterTextInputViewSpy* inputView = [[FlutterTextInputViewSpy alloc] init]; |
| inputView.textInputDelegate = engine; |
| UIView* container = [[UIView alloc] init]; |
| UIAccessibilityElement* backing = |
| [[UIAccessibilityElement alloc] initWithAccessibilityContainer:container]; |
| inputView.backingTextInputAccessibilityObject = backing; |
| // Simulate accessibility focus. |
| inputView.isAccessibilityFocused = YES; |
| [inputView accessibilityElementDidBecomeFocused]; |
| |
| XCTAssertEqual(inputView.receivedNotification, UIAccessibilityScreenChangedNotification); |
| XCTAssertEqual(inputView.receivedNotificationTarget, backing); |
| } |
| |
| @end |