From 104df6bba4c2a0ca97d60aa85dcbd697cd3836fb Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Thu, 16 Apr 2020 02:47:53 -0700 Subject: [PATCH] Add autofill support to ios text input plugin (#17493) --- .../ios/framework/Source/FlutterEngine.mm | 5 + .../Source/FlutterTextInputDelegate.h | 1 + .../framework/Source/FlutterTextInputPlugin.h | 4 +- .../Source/FlutterTextInputPlugin.mm | 282 +++++++++++++++--- .../Source/FlutterTextInputPluginTest.m | 61 ++++ 5 files changed, 304 insertions(+), 49 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index c3302194b039a..35cf9553b0c61 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -534,6 +534,11 @@ - (void)updateEditingClient:(int)client withState:(NSDictionary*)state { arguments:@[ @(client), state ]]; } +- (void)updateEditingClient:(int)client withState:(NSDictionary*)state withTag:(NSString*)tag { + [_textInputChannel.get() invokeMethod:@"TextInputClient.updateEditingStateWithTag" + arguments:@[ @(client), @{tag : state} ]]; +} + - (void)updateFloatingCursor:(FlutterFloatingCursorDragState)state withClient:(int)client withPosition:(NSDictionary*)position { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h index 8446a79946d63..033a196696127 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h @@ -30,6 +30,7 @@ typedef NS_ENUM(NSInteger, FlutterFloatingCursorDragState) { @protocol FlutterTextInputDelegate - (void)updateEditingClient:(int)client withState:(NSDictionary*)state; +- (void)updateEditingClient:(int)client withState:(NSDictionary*)state withTag:(NSString*)tag; - (void)performAction:(FlutterTextInputAction)action withClient:(int)client; - (void)updateFloatingCursor:(FlutterFloatingCursorDragState)state withClient:(int)client diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h index 3677c0f78284a..c5571ee10e0b3 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h @@ -36,9 +36,6 @@ @end /** A range of text in the buffer of a Flutter text editing widget. */ -#if FLUTTER_RUNTIME_MODE == FLUTTER_RUNTIME_MODE_DEBUG -FLUTTER_EXPORT -#endif @interface FlutterTextRange : UITextRange @property(nonatomic, readonly) NSRange range; @@ -71,6 +68,7 @@ FLUTTER_EXPORT @property(nonatomic, getter=isSecureTextEntry) BOOL secureTextEntry; @property(nonatomic) UITextSmartQuotesType smartQuotesType API_AVAILABLE(ios(11.0)); @property(nonatomic) UITextSmartDashesType smartDashesType API_AVAILABLE(ios(11.0)); +@property(nonatomic, copy) UITextContentType textContentType API_AVAILABLE(ios(10.0)); @property(nonatomic, assign) id textInputDelegate; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index f9ddd6621d6d8..068b8bcb76622 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -88,6 +88,122 @@ static UIReturnKeyType ToUIReturnKeyType(NSString* inputType) { return UIReturnKeyDefault; } +static UITextContentType ToUITextContentType(NSArray* hints) { + if (hints == nil || hints.count == 0) { + return @""; + } + + NSString* hint = hints[0]; + if (@available(iOS 10.0, *)) { + if ([hint isEqualToString:@"addressCityAndState"]) { + return UITextContentTypeAddressCityAndState; + } + + if ([hint isEqualToString:@"addressState"]) { + return UITextContentTypeAddressState; + } + + if ([hint isEqualToString:@"addressCity"]) { + return UITextContentTypeAddressCity; + } + + if ([hint isEqualToString:@"sublocality"]) { + return UITextContentTypeSublocality; + } + + if ([hint isEqualToString:@"streetAddressLine1"]) { + return UITextContentTypeStreetAddressLine1; + } + + if ([hint isEqualToString:@"streetAddressLine2"]) { + return UITextContentTypeStreetAddressLine2; + } + + if ([hint isEqualToString:@"countryName"]) { + return UITextContentTypeCountryName; + } + + if ([hint isEqualToString:@"fullStreetAddress"]) { + return UITextContentTypeFullStreetAddress; + } + + if ([hint isEqualToString:@"postalCode"]) { + return UITextContentTypePostalCode; + } + + if ([hint isEqualToString:@"location"]) { + return UITextContentTypeLocation; + } + + if ([hint isEqualToString:@"creditCardNumber"]) { + return UITextContentTypeCreditCardNumber; + } + + if ([hint isEqualToString:@"email"]) { + return UITextContentTypeEmailAddress; + } + + if ([hint isEqualToString:@"jobTitle"]) { + return UITextContentTypeJobTitle; + } + + if ([hint isEqualToString:@"givenName"]) { + return UITextContentTypeGivenName; + } + + if ([hint isEqualToString:@"middleName"]) { + return UITextContentTypeMiddleName; + } + + if ([hint isEqualToString:@"familyName"]) { + return UITextContentTypeFamilyName; + } + + if ([hint isEqualToString:@"name"]) { + return UITextContentTypeName; + } + + if ([hint isEqualToString:@"namePrefix"]) { + return UITextContentTypeNamePrefix; + } + + if ([hint isEqualToString:@"nameSuffix"]) { + return UITextContentTypeNameSuffix; + } + + if ([hint isEqualToString:@"nickname"]) { + return UITextContentTypeNickname; + } + + if ([hint isEqualToString:@"organizationName"]) { + return UITextContentTypeOrganizationName; + } + + if ([hint isEqualToString:@"telephoneNumber"]) { + return UITextContentTypeTelephoneNumber; + } + } + + if (@available(iOS 11.0, *)) { + if ([hint isEqualToString:@"password"]) { + return UITextContentTypePassword; + } + } + + if (@available(iOS 12.0, *)) { + if ([hint isEqualToString:@"oneTimeCode"]) { + return UITextContentTypeOneTimeCode; + } + } + + return hints[0]; +} + +static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) { + NSDictionary* autofill = dictionary[@"autofill"]; + return autofill == nil ? nil : autofill[@"uniqueIdentifier"]; +} + #pragma mark - FlutterTextPosition @implementation FlutterTextPosition @@ -140,6 +256,10 @@ - (id)copyWithZone:(NSZone*)zone { @end +@interface FlutterTextInputView () +@property(nonatomic, copy) NSString* autofillId; +@end + @implementation FlutterTextInputView { int _textInputClient; const char* _selectionAffinity; @@ -184,6 +304,7 @@ - (void)dealloc { [_markedTextRange release]; [_selectedTextRange release]; [_tokenizer release]; + [_autofillId release]; [super dealloc]; } @@ -241,7 +362,10 @@ - (NSRange)clampSelection:(NSRange)range forText:(NSString*)text { #pragma mark - UIResponder Overrides - (BOOL)canBecomeFirstResponder { - return YES; + // Only the currently focused input field can + // become the first responder. This prevents iOS + // from changing focus by itself. + return _textInputClient != 0; } #pragma mark - UITextInput Overrides @@ -600,16 +724,22 @@ - (void)updateEditingState { composingBase = ((FlutterTextPosition*)self.markedTextRange.start).index; composingExtent = ((FlutterTextPosition*)self.markedTextRange.end).index; } - [_textInputDelegate updateEditingClient:_textInputClient - withState:@{ - @"selectionBase" : @(selectionBase), - @"selectionExtent" : @(selectionExtent), - @"selectionAffinity" : @(_selectionAffinity), - @"selectionIsDirectional" : @(false), - @"composingBase" : @(composingBase), - @"composingExtent" : @(composingExtent), - @"text" : [NSString stringWithString:self.text], - }]; + + NSDictionary* state = @{ + @"selectionBase" : @(selectionBase), + @"selectionExtent" : @(selectionExtent), + @"selectionAffinity" : @(_selectionAffinity), + @"selectionIsDirectional" : @(false), + @"composingBase" : @(composingBase), + @"composingExtent" : @(composingExtent), + @"text" : [NSString stringWithString:self.text], + }; + + if (_textInputClient == 0 && _autofillId != nil) { + [_textInputDelegate updateEditingClient:_textInputClient withState:state withTag:_autofillId]; + } else { + [_textInputDelegate updateEditingClient:_textInputClient withState:state]; + } } - (BOOL)hasText { @@ -671,12 +801,15 @@ - (BOOL)accessibilityElementsHidden { @end -@implementation FlutterTextInputPlugin { - FlutterTextInputView* _view; - FlutterTextInputView* _secureView; - FlutterTextInputView* _activeView; - FlutterTextInputViewAccessibilityHider* _inputHider; -} +@interface FlutterTextInputPlugin () +@property(nonatomic, retain) FlutterTextInputView* nonAutofillInputView; +@property(nonatomic, retain) FlutterTextInputView* nonAutofillSecureInputView; +@property(nonatomic, retain) NSMutableArray* inputViews; +@property(nonatomic, assign) FlutterTextInputView* activeView; +@property(nonatomic, retain) FlutterTextInputViewAccessibilityHider* inputHider; +@end + +@implementation FlutterTextInputPlugin @synthesize textInputDelegate = _textInputDelegate; @@ -684,12 +817,13 @@ - (instancetype)init { self = [super init]; if (self) { - _view = [[FlutterTextInputView alloc] init]; - _view.secureTextEntry = NO; - _secureView = [[FlutterTextInputView alloc] init]; - _secureView.secureTextEntry = YES; + _nonAutofillInputView = [[FlutterTextInputView alloc] init]; + _nonAutofillInputView.secureTextEntry = NO; + _nonAutofillInputView = [[FlutterTextInputView alloc] init]; + _nonAutofillSecureInputView.secureTextEntry = YES; + _inputViews = [[NSMutableArray alloc] init]; - _activeView = _view; + _activeView = _nonAutofillInputView; _inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init]; } @@ -698,9 +832,10 @@ - (instancetype)init { - (void)dealloc { [self hideTextInput]; - [_view release]; - [_secureView release]; + [_nonAutofillInputView release]; + [_nonAutofillSecureInputView release]; [_inputHider release]; + [_inputViews release]; [super dealloc]; } @@ -733,58 +868,113 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } - (void)showTextInput { - NSAssert([UIApplication sharedApplication].keyWindow != nullptr, + UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; + NSAssert(keyWindow != nullptr, @"The application must have a key window since the keyboard client " @"must be part of the responder chain to function"); _activeView.textInputDelegate = _textInputDelegate; - [_inputHider addSubview:_activeView]; - [[UIApplication sharedApplication].keyWindow addSubview:_inputHider]; + if (![_activeView isDescendantOfView:_inputHider]) { + [_inputHider addSubview:_activeView]; + } + [keyWindow addSubview:_inputHider]; [_activeView becomeFirstResponder]; } - (void)hideTextInput { [_activeView resignFirstResponder]; - [_activeView removeFromSuperview]; [_inputHider removeFromSuperview]; } - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration { - NSDictionary* inputType = configuration[@"inputType"]; - NSString* keyboardAppearance = configuration[@"keyboardAppearance"]; - if ([configuration[@"obscureText"] boolValue]) { - _activeView = _secureView; + NSArray* fields = configuration[@"fields"]; + NSString* clientUniqueId = uniqueIdFromDictionary(configuration); + bool isSecureTextEntry = [configuration[@"obscureText"] boolValue]; + + if (fields == nil) { + _activeView = isSecureTextEntry ? _nonAutofillSecureInputView : _nonAutofillInputView; + [FlutterTextInputPlugin setupInputView:_activeView withConfiguration:configuration]; + + if (![_activeView isDescendantOfView:_inputHider]) { + [_inputHider addSubview:_activeView]; + } } else { - _activeView = _view; + NSAssert(clientUniqueId != nil, @"The client's unique id can't be null"); + for (FlutterTextInputView* view in _inputViews) { + [view removeFromSuperview]; + } + for (UIView* subview in _inputHider.subviews) { + [subview removeFromSuperview]; + } + + [_inputViews removeAllObjects]; + + for (NSDictionary* field in fields) { + FlutterTextInputView* newInputView = [[[FlutterTextInputView alloc] init] autorelease]; + newInputView.textInputDelegate = _textInputDelegate; + [_inputViews addObject:newInputView]; + + NSString* autofillId = uniqueIdFromDictionary(field); + newInputView.autofillId = autofillId; + + if ([clientUniqueId isEqualToString:autofillId]) { + _activeView = newInputView; + } + + [FlutterTextInputPlugin setupInputView:newInputView withConfiguration:field]; + [_inputHider addSubview:newInputView]; + } } - _activeView.keyboardType = ToUIKeyboardType(inputType); - _activeView.returnKeyType = ToUIReturnKeyType(configuration[@"inputAction"]); - _activeView.autocapitalizationType = ToUITextAutoCapitalizationType(configuration); + [_activeView setTextInputClient:client]; + [_activeView reloadInputViews]; +} + ++ (void)setupInputView:(FlutterTextInputView*)inputView + withConfiguration:(NSDictionary*)configuration { + NSDictionary* inputType = configuration[@"inputType"]; + NSString* keyboardAppearance = configuration[@"keyboardAppearance"]; + NSDictionary* autofill = configuration[@"autofill"]; + + inputView.secureTextEntry = [configuration[@"obscureText"] boolValue]; + inputView.keyboardType = ToUIKeyboardType(inputType); + inputView.returnKeyType = ToUIReturnKeyType(configuration[@"inputAction"]); + inputView.autocapitalizationType = ToUITextAutoCapitalizationType(configuration); + if (@available(iOS 11.0, *)) { NSString* smartDashesType = configuration[@"smartDashesType"]; // This index comes from the SmartDashesType enum in the framework. bool smartDashesIsDisabled = smartDashesType && [smartDashesType isEqualToString:@"0"]; - _activeView.smartDashesType = + inputView.smartDashesType = smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes; NSString* smartQuotesType = configuration[@"smartQuotesType"]; // This index comes from the SmartQuotesType enum in the framework. bool smartQuotesIsDisabled = smartQuotesType && [smartQuotesType isEqualToString:@"0"]; - _activeView.smartQuotesType = + inputView.smartQuotesType = smartQuotesIsDisabled ? UITextSmartQuotesTypeNo : UITextSmartQuotesTypeYes; } if ([keyboardAppearance isEqualToString:@"Brightness.dark"]) { - _activeView.keyboardAppearance = UIKeyboardAppearanceDark; + inputView.keyboardAppearance = UIKeyboardAppearanceDark; } else if ([keyboardAppearance isEqualToString:@"Brightness.light"]) { - _activeView.keyboardAppearance = UIKeyboardAppearanceLight; + inputView.keyboardAppearance = UIKeyboardAppearanceLight; } else { - _activeView.keyboardAppearance = UIKeyboardAppearanceDefault; + inputView.keyboardAppearance = UIKeyboardAppearanceDefault; } NSString* autocorrect = configuration[@"autocorrect"]; - _activeView.autocorrectionType = autocorrect && ![autocorrect boolValue] - ? UITextAutocorrectionTypeNo - : UITextAutocorrectionTypeDefault; - [_activeView setTextInputClient:client]; - [_activeView reloadInputViews]; + inputView.autocorrectionType = autocorrect && ![autocorrect boolValue] + ? UITextAutocorrectionTypeNo + : UITextAutocorrectionTypeDefault; + if (@available(iOS 10.0, *)) { + if (autofill == nil) { + inputView.textContentType = @""; + } else { + inputView.textContentType = ToUITextContentType(autofill[@"hints"]); + [inputView setTextInputState:autofill[@"editingValue"]]; + // An input field needs to be visible in order to get + // autofilled when it's not the one that triggered + // autofill. + inputView.frame = CGRectMake(0, 0, 1, 1); + } + } } - (void)setTextInputEditingState:(NSDictionary*)state { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m index 5355e92fa3adc..df6e1a5a2fb8b 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m @@ -15,6 +15,67 @@ @interface FlutterTextInputPluginTest : XCTestCase @implementation FlutterTextInputPluginTest +- (void)testAutofillInputViews { + // Setup test. + id engine = OCMClassMock([FlutterEngine class]); + FlutterTextInputPlugin* textInputPlugin = [[FlutterTextInputPlugin alloc] init]; + textInputPlugin.textInputDelegate = engine; + + NSDictionary* template = @{ + @"inputType" : @{@"name" : @"TextInuptType.text"}, + @"keyboardAppearance" : @"Brightness.light", + @"obscureText" : @NO, + @"inputAction" : @"TextInputAction.unspecified", + @"smartDashesType" : @"0", + @"smartQuotesType" : @"0", + @"autocorrect" : @YES + }; + + NSMutableDictionary* field1 = [template mutableCopy]; + [field1 setValue:@{ + @"uniqueIdentifier" : @"field1", + @"hints" : @[ @"hint1" ], + @"editingValue" : @{@"text" : @""} + } + forKey:@"autofill"]; + + NSMutableDictionary* field2 = [template mutableCopy]; + [field2 setValue:@{ + @"uniqueIdentifier" : @"field2", + @"hints" : @[ @"hint2" ], + @"editingValue" : @{@"text" : @""} + } + forKey:@"autofill"]; + + NSMutableDictionary* config = [field1 mutableCopy]; + [config setValue:@[ field1, field2 ] forKey:@"fields"]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @123, config ]]; + + [textInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + // Find all input views in the input hider view. + NSArray* inputFields = + [[[textInputPlugin textInputView] superview] subviews]; + + XCTAssertEqual(inputFields.count, 2); + + // Find the inactive autofillable input field. + FlutterTextInputView* inactiveView = inputFields[1]; + [inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)] + withText:@"Autofilled!"]; + + // Verify behavior. + OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]); + + // Clean up mocks + [engine stopMocking]; +} + - (void)testAutocorrectionPromptRectAppears { // Setup test. id engine = OCMClassMock([FlutterEngine class]);