From 5184d7f162ab6cc5e1e0b6986ef57db51ebfeefd Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Tue, 26 May 2020 19:18:19 -0700 Subject: [PATCH 01/14] WIP --- .../Source/FlutterTextInputPlugin.mm | 332 +++++++++++++----- .../Source/FlutterTextInputPluginTest.m | 301 +++++++++++----- 2 files changed, 465 insertions(+), 168 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 225b1e51bd069..2bb920b1921bf 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -211,7 +211,58 @@ static UITextContentType ToUITextContentType(NSArray* hints) { static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) { NSDictionary* autofill = dictionary[@"autofill"]; - return autofill == nil ? nil : autofill[@"uniqueIdentifier"]; + if (autofill) { + return autofill[@"uniqueIdentifier"]; + } + + // When autofill is nil, the field may still need an autofill id + // if the field is for password. + return [dictionary[@"obscureText"] boolValue] ? @"password" : nil; +} + +// We refer to password/username autofill, one-time-code autofill, +// and predictive text for contact information etc. as autofill. +typedef NS_ENUM(NSInteger, FlutterAutofillType) { + FlutterAutofillTypeNone, + FlutterAutofillTypeRegular, + FlutterAutofillTypePassword, +}; + +static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { + NSArray* fields = configuration[@"fields"]; + BOOL isSecureTextEntry = [configuration[@"obscureText"] boolValue]; + + if (@available(iOS 10.0, *)) { + // Currently iOS only fills the focused field unless it's a password + // autofill. As an optimization, the framework will only send configurations + // for multiple input fields when there's a password input field among them. + // When the configuration contains more than 1 field, we consider this + // autofill group password related. + if (isSecureTextEntry || (fields && [fields count] > 1)) + return FlutterAutofillTypePassword; + + if (uniqueIdFromDictionary(configuration) == nil) { + return FlutterAutofillTypeNone; + } + + NSDictionary* autofill = configuration[@"autofill"]; + UITextContentType contentType = ToUITextContentType(autofill[@"hints"]); + + if (@available(iOS 11.0, *)) { + if ([contentType isEqualToString:UITextContentTypePassword] || + [contentType isEqualToString:UITextContentTypeUsername]) { + return FlutterAutofillTypePassword; + } + } + + if (@available(iOS 12.0, *)) { + if ([contentType isEqualToString:UITextContentTypeNewPassword]) { + return FlutterAutofillTypePassword; + } + } + } + + return FlutterAutofillTypeNone; } #pragma mark - FlutterTextPosition @@ -269,6 +320,44 @@ - (BOOL)isEqualTo:(FlutterTextRange*)other { } @end +// A FlutterTextInputView that masquerades as a UITextField, and forwards +// selectors it can't respond to to a shared UITextField instance. +// +// Relevant API docs claim that password autofill supports any custom view +// that adopts the UITextInput protocol, auto strong password seems to +// currently only support UITextFields and saving username/password +// for autofill only supports UITextFields and UITextViews, as of iOS 13.5. +@interface FlutterSecureTextInputView : FlutterTextInputView +@property(class, nonatomic, assign, readonly) UITextField* textField; +@end + +@implementation FlutterSecureTextInputView ++ (UITextField*)textField { + static UITextField* _textField = nil; + if (_textField == nil) { + _textField = [[UITextField alloc] init]; + } + return _textField; +} + +- (BOOL)isKindOfClass:(Class)aClass { + return [super isKindOfClass:aClass] || (aClass == [UITextField class]); +} + +- (NSMethodSignature*)methodSignatureForSelector:(SEL)aSelector { + NSMethodSignature* signature = [super methodSignatureForSelector:aSelector]; + if (!signature) { + signature = [[FlutterSecureTextInputView textField] methodSignatureForSelector:aSelector]; + } + return signature; +} + +- (void)forwardInvocation:(NSInvocation*)anInvocation { + [anInvocation invokeWithTarget:[FlutterSecureTextInputView textField]]; +} + +@end + @interface FlutterTextInputView () @property(nonatomic, copy) NSString* autofillId; @end @@ -311,6 +400,57 @@ - (instancetype)init { return self; } +- (void)configureWithDictionary:(NSDictionary*)configuration { + NSDictionary* inputType = configuration[@"inputType"]; + NSString* keyboardAppearance = configuration[@"keyboardAppearance"]; + NSDictionary* autofill = configuration[@"autofill"]; + + self.secureTextEntry = [configuration[@"obscureText"] boolValue]; + self.keyboardType = ToUIKeyboardType(inputType); + self.keyboardType = UIKeyboardTypeNamePhonePad; + self.returnKeyType = ToUIReturnKeyType(configuration[@"inputAction"]); + self.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"]; + self.smartDashesType = + smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes; + NSString* smartQuotesType = configuration[@"smartQuotesType"]; + // This index comes from the SmartQuotesType enum in the framework. + bool smartQuotesIsDisabled = smartQuotesType && [smartQuotesType isEqualToString:@"0"]; + self.smartQuotesType = + smartQuotesIsDisabled ? UITextSmartQuotesTypeNo : UITextSmartQuotesTypeYes; + } + if ([keyboardAppearance isEqualToString:@"Brightness.dark"]) { + self.keyboardAppearance = UIKeyboardAppearanceDark; + } else if ([keyboardAppearance isEqualToString:@"Brightness.light"]) { + self.keyboardAppearance = UIKeyboardAppearanceLight; + } else { + self.keyboardAppearance = UIKeyboardAppearanceDefault; + } + NSString* autocorrect = configuration[@"autocorrect"]; + self.autocorrectionType = autocorrect && ![autocorrect boolValue] + ? UITextAutocorrectionTypeNo + : UITextAutocorrectionTypeDefault; + if (@available(iOS 10.0, *)) { + if (autofill == nil) { + self.textContentType = @""; + } else { + self.textContentType = ToUITextContentType(autofill[@"hints"]); + [self setTextInputState:autofill[@"editingValue"]]; + // The input field needs to be visible for the system autofill + // to find it. + self.frame = CGRectMake(0, 0, 1, 1); + } + } +} + +- (UITextContentType)textContentType { + return _textContentType; +} + - (void)dealloc { [_text release]; [_markedText release]; @@ -672,7 +812,7 @@ - (void)setBaseWritingDirection:(UITextWritingDirection)writingDirection // physical keyboard. - (CGRect)firstRectForRange:(UITextRange*)range { - // multi-stage text is handled somewhere else. + // multi-stage text is handled in the framework. if (_markedTextRange != nil) { return CGRectZero; } @@ -816,9 +956,11 @@ - (BOOL)accessibilityElementsHidden { @end @interface FlutterTextInputPlugin () -@property(nonatomic, retain) FlutterTextInputView* nonAutofillInputView; -@property(nonatomic, retain) FlutterTextInputView* nonAutofillSecureInputView; -@property(nonatomic, retain) NSMutableArray* inputViews; +@property(nonatomic, retain) FlutterTextInputView* singleInputView; + +// The current password-autofillable input fields that have yet to be saved. +@property(nonatomic, readonly) + NSMutableDictionary* autofillContext; @property(nonatomic, assign) FlutterTextInputView* activeView; @end @@ -830,13 +972,11 @@ - (instancetype)init { self = [super init]; if (self) { - _nonAutofillInputView = [[FlutterTextInputView alloc] init]; - _nonAutofillInputView.secureTextEntry = NO; - _nonAutofillSecureInputView = [[FlutterTextInputView alloc] init]; - _nonAutofillSecureInputView.secureTextEntry = YES; - _inputViews = [[NSMutableArray alloc] init]; + _singleInputView = [[FlutterTextInputView alloc] init]; + _singleInputView.secureTextEntry = NO; + _autofillContext = [[NSMutableDictionary alloc] init]; - _activeView = _nonAutofillInputView; + _activeView = _singleInputView; } return self; @@ -844,9 +984,8 @@ - (instancetype)init { - (void)dealloc { [self hideTextInput]; - [_nonAutofillInputView release]; - [_nonAutofillSecureInputView release]; - [_inputViews release]; + [_singleInputView release]; + [_autofillContext release]; [super dealloc]; } @@ -873,6 +1012,12 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } else if ([method isEqualToString:@"TextInput.clearClient"]) { [self clearTextInputClient]; result(nil); + } else if ([method isEqualToString:@"TextInput.AutofillContext.commit"]) { + [self triggerAutofillSave:YES]; + result(nil); + } else if ([method isEqualToString:@"TextInput.AutofillContext.clear"]) { + [self triggerAutofillSave:NO]; + result(nil); } else { result(FlutterMethodNotImplemented); } @@ -895,98 +1040,113 @@ - (void)hideTextInput { [_activeView resignFirstResponder]; } +- (void)triggerAutofillSave:(BOOL)saveEntries { + [self hideTextInput]; + + if (!saveEntries) { + [_autofillContext + enumerateKeysAndObjectsUsingBlock:^( + NSString* _Nonnull key, FlutterTextInputView* _Nonnull obj, BOOL* _Nonnull stop) { + [obj.text setString:@""]; + }]; + } + + [_autofillContext removeAllObjects]; + [self removeNonPasswordAutofillFields:NO]; + UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; + if (_activeView.window != keyWindow) { + [keyWindow addSubview:_activeView]; + } +} + - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration { UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; 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]; + [self removeNonPasswordAutofillFields:YES]; + FlutterAutofillType autofillType = autofillTypeOf(configuration); - if (_activeView.window != keyWindow) { - [keyWindow addSubview:_activeView]; - } - } else { - NSAssert(clientUniqueId != nil, @"The client's unique id can't be null"); - for (FlutterTextInputView* view in _inputViews) { - [view removeFromSuperview]; - } + switch (autofillType) { + case FlutterAutofillTypeNone: + _activeView = _singleInputView; - for (UIView* view in keyWindow.subviews) { - if ([view isKindOfClass:[FlutterTextInputView class]]) { - [view removeFromSuperview]; + [_activeView configureWithDictionary:configuration]; + if (_activeView.window != keyWindow) { + [keyWindow addSubview:_activeView]; } - } + break; + case FlutterAutofillTypeRegular: + _activeView = [self updateAutofillableView:(fields ? fields : @[ configuration ]) + isPasswordAutofill:NO + focusedId:clientUniqueId]; + break; + case FlutterAutofillTypePassword: + _activeView = [self updateAutofillableView:(fields ? fields : @[ configuration ]) + isPasswordAutofill:YES + focusedId:clientUniqueId]; + break; + } - [_inputViews removeAllObjects]; + [_activeView setTextInputClient:client]; + [_activeView reloadInputViews]; +} - for (NSDictionary* field in fields) { - FlutterTextInputView* newInputView = [[[FlutterTextInputView alloc] init] autorelease]; - newInputView.textInputDelegate = _textInputDelegate; - [_inputViews addObject:newInputView]; +- (FlutterTextInputView*)updateAutofillableView:(NSArray*)fields + isPasswordAutofill:(BOOL)needsPasswordAutofill + focusedId:(NSString*)focusedId { + FlutterTextInputView* focused = nil; - NSString* autofillId = uniqueIdFromDictionary(field); - newInputView.autofillId = autofillId; + for (NSDictionary* field in fields) { + NSString* autofillId = uniqueIdFromDictionary(field); + NSAssert(autofillId, @"autofillId must not be null for field: %@", field); + FlutterTextInputView* newInputView = [self getOrCreateAutofillableView:field + isPasswordAutofill:needsPasswordAutofill]; - if ([clientUniqueId isEqualToString:autofillId]) { - _activeView = newInputView; - } + if ([focusedId isEqualToString:autofillId]) { + focused = newInputView; + } - [FlutterTextInputPlugin setupInputView:newInputView withConfiguration:field]; - [keyWindow addSubview:newInputView]; + // Push the current autofillable input fields to the context if they're + // password-related. + if (needsPasswordAutofill) { + _autofillContext[autofillId] = newInputView; } } - [_activeView setTextInputClient:client]; - [_activeView reloadInputViews]; + NSAssert(focused, @"The current focused input view must not be nil."); + return focused; } -+ (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); +- (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field + isPasswordAutofill:(BOOL)needsPasswordAutofill { + NSString* autofillId = uniqueIdFromDictionary(field); + UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; - 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"]; - 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"]; - inputView.smartQuotesType = - smartQuotesIsDisabled ? UITextSmartQuotesTypeNo : UITextSmartQuotesTypeYes; + FlutterTextInputView* inputView = _autofillContext[autofillId]; + if (!inputView) { + inputView = + needsPasswordAutofill ? [FlutterSecureTextInputView alloc] : [FlutterTextInputView alloc]; + inputView = [[inputView init] autorelease]; + [keyWindow addSubview:inputView]; } - if ([keyboardAppearance isEqualToString:@"Brightness.dark"]) { - inputView.keyboardAppearance = UIKeyboardAppearanceDark; - } else if ([keyboardAppearance isEqualToString:@"Brightness.light"]) { - inputView.keyboardAppearance = UIKeyboardAppearanceLight; - } else { - inputView.keyboardAppearance = UIKeyboardAppearanceDefault; - } - NSString* autocorrect = configuration[@"autocorrect"]; - 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); + + inputView.textInputDelegate = _textInputDelegate; + inputView.autofillId = autofillId; + [inputView configureWithDictionary:field]; + return inputView; +} + +- (void)removeNonPasswordAutofillFields:(BOOL)removeNonAutofillField { + UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; + + for (UIView* view in keyWindow.subviews) { + if ([view isKindOfClass:[FlutterTextInputView class]] && + (removeNonAutofillField || view != _singleInputView)) { + FlutterTextInputView* inputView = (FlutterTextInputView*)view; + if (!_autofillContext[inputView.autofillId]) { + [view removeFromSuperview]; + } } } } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m index 678ed1543c674..7a9424258e4f0 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m @@ -10,43 +10,93 @@ FLUTTER_ASSERT_ARC -@interface FlutterTextInputPluginTest : XCTestCase -@end - @interface FlutterTextInputView () +@property(nonatomic, copy) NSString* autofillId; - (void)setTextInputState:(NSDictionary*)state; @end -@implementation FlutterTextInputPluginTest -- (void)testSecureInput { - // Setup test. - id engine = OCMClassMock([FlutterEngine class]); - FlutterTextInputPlugin* textInputPlugin = [[FlutterTextInputPlugin alloc] init]; +@interface FlutterTextInputPlugin () +@property(nonatomic, retain) FlutterTextInputView* singleInputView; +@property(nonatomic, readonly) + NSMutableDictionary* autofillContext; +@end + +@interface FlutterTextInputPluginTest : XCTestCase +@end + +@implementation FlutterTextInputPluginTest { + NSDictionary* _template; + id engine; + FlutterTextInputPlugin* textInputPlugin; +} +- (void)setUp { + [super setUp]; + + engine = OCMClassMock([FlutterEngine class]); + textInputPlugin = [[FlutterTextInputPlugin alloc] init]; textInputPlugin.textInputDelegate = engine; +} - NSDictionary* config = @{ - @"inputType" : @{@"name" : @"TextInuptType.text"}, - @"keyboardAppearance" : @"Brightness.light", - @"obscureText" : @YES, - @"inputAction" : @"TextInputAction.unspecified", - @"smartDashesType" : @"0", - @"smartQuotesType" : @"0", - @"autocorrect" : @YES - }; +- (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:@[ @123, config ]]; - + arguments:@[ [NSNumber numberWithInt:clientId], config ]]; [textInputPlugin handleMethodCall:setClientCall result:^(id _Nullable result){ }]; +} - // Find all the FlutterTextInputViews we created. - NSArray* inputFields = [[[[textInputPlugin textInputView] superview] - subviews] - filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@", +- (void)commitAutofillContext { + FlutterMethodCall* methodCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.AutofillContext.commit" + arguments:nil]; + [textInputPlugin handleMethodCall:methodCall + 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*)installedInputViews { + UIWindow* keyWindow = + [[[UIApplication sharedApplication] windows] + filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isKeyWindow == YES"]] + .firstObject; + + return [keyWindow.subviews + filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@", [FlutterTextInputView class]]]; +} + +- (void)testSecureInput { + NSDictionary* config = self.mutableTemplateCopy; + [config setValue:@"YES" forKey:@"obscureText"]; + [self setClientId:123 configuration:config]; + + // Find all the FlutterTextInputViews we created. + NSArray* 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. @@ -62,16 +112,12 @@ - (void)testSecureInput { // plugin's active text input view. XCTAssertEqual(inputView, textInputPlugin.textInputView); - // Clean up. - [engine stopMocking]; - [[[[textInputPlugin textInputView] superview] subviews] - makeObjectsPerformSelector:@selector(removeFromSuperview)]; + // Despite not given an id in configuration, inputView has + // an autofill id. + XCTAssert(inputView.autofillId.length > 0); } - (void)testTextChangesTriggerUpdateEditingClient { - // Setup test. - id engine = OCMClassMock([FlutterEngine class]); - FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; inputView.textInputDelegate = engine; @@ -86,15 +132,9 @@ - (void)testTextChangesTriggerUpdateEditingClient { // Don't send anything if there's nothing new. [inputView setTextInputState:@{@"text" : @"AFTER"}]; OCMReject([engine updateEditingClient:0 withState:[OCMArg any]]); - - // Clean up. - [engine stopMocking]; } - (void)testSelectionChangeTriggersUpdateEditingClient { - // Setup test. - id engine = OCMClassMock([FlutterEngine class]); - FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; inputView.textInputDelegate = engine; @@ -118,15 +158,9 @@ - (void)testSelectionChangeTriggersUpdateEditingClient { [inputView setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @2}]; OCMReject([engine updateEditingClient:0 withState:[OCMArg any]]); - - // Clean up. - [engine stopMocking]; } - (void)testComposingChangeTriggersUpdateEditingClient { - // Setup test. - id engine = OCMClassMock([FlutterEngine class]); - FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init]; inputView.textInputDelegate = engine; @@ -151,28 +185,91 @@ - (void)testComposingChangeTriggersUpdateEditingClient { [inputView setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}]; OCMReject([engine updateEditingClient:0 withState:[OCMArg any]]); - - // Clean up. - [engine stopMocking]; } -- (void)testAutofillInputViews { - // Setup test. - id engine = OCMClassMock([FlutterEngine class]); - FlutterTextInputPlugin* textInputPlugin = [[FlutterTextInputPlugin alloc] init]; - textInputPlugin.textInputDelegate = engine; +- (void)testAutofillContext { + NSMutableDictionary* field1 = self.mutableTemplateCopy; + [field1 setValue:@{ + @"uniqueIdentifier" : @"field1", + @"hints" : @[ @"hint1" ], + @"editingValue" : @{@"text" : @""} + } + forKey:@"autofill"]; + + NSMutableDictionary* field2 = self.mutableTemplateCopy; + [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(textInputPlugin.autofillContext.count, 2); + XCTAssertEqual(self.installedInputViews.count, 2); + XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); + + // The configuration changes. + NSMutableDictionary* field3 = self.mutableTemplateCopy; + [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(textInputPlugin.autofillContext.count, 3); + XCTAssertEqual(self.installedInputViews.count, 3); + XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); + + // 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, nor in an AutofillGroup. + config = self.mutableTemplateCopy; + [config setValue:@"YES" forKey:@"obscureText"]; + + oldContext = textInputPlugin.autofillContext; + [self setClientId:124 configuration:config]; + + XCTAssertEqual(textInputPlugin.autofillContext.count, 4); + XCTAssertEqual(self.installedInputViews.count, 4); - NSDictionary* template = @{ - @"inputType" : @{@"name" : @"TextInuptType.text"}, - @"keyboardAppearance" : @"Brightness.light", - @"obscureText" : @NO, - @"inputAction" : @"TextInputAction.unspecified", - @"smartDashesType" : @"0", - @"smartQuotesType" : @"0", - @"autocorrect" : @YES - }; - - NSMutableDictionary* field1 = [template mutableCopy]; + // Old autofill input fields are still installed and reused. + for (NSString* key in oldContext.allKeys) { + XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]); + } + // The active view changes. + XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); + + // Switch to a similar password field, the previous field should be reused. + oldContext = textInputPlugin.autofillContext; + [self setClientId:200 configuration:config]; + + XCTAssertEqual(textInputPlugin.autofillContext.count, 4); + 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"]); +} + +- (void)testCommitAutofillContext { + NSMutableDictionary* field1 = self.mutableTemplateCopy; [field1 setValue:@{ @"uniqueIdentifier" : @"field1", @"hints" : @[ @"hint1" ], @@ -180,7 +277,7 @@ - (void)testAutofillInputViews { } forKey:@"autofill"]; - NSMutableDictionary* field2 = [template mutableCopy]; + NSMutableDictionary* field2 = self.mutableTemplateCopy; [field2 setValue:@{ @"uniqueIdentifier" : @"field2", @"hints" : @[ @"hint2" ], @@ -191,19 +288,54 @@ - (void)testAutofillInputViews { NSMutableDictionary* config = [field1 mutableCopy]; [config setValue:@[ field1, field2 ] forKey:@"fields"]; - FlutterMethodCall* setClientCall = - [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" - arguments:@[ @123, config ]]; + [self setClientId:123 configuration:config]; - [textInputPlugin handleMethodCall:setClientCall - result:^(id _Nullable result){ - }]; + [self commitAutofillContext]; + + 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); + + [self setClientId:124 configuration:config]; + // Now switch to a regular field (no autofill). + [self setClientId:125 configuration:self.mutableTemplateCopy]; + + XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.singleInputView); + XCTAssertEqual(self.installedInputViews.count, 3); + XCTAssertEqual(textInputPlugin.autofillContext.count, 2); + + [self commitAutofillContext]; + XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.singleInputView); + XCTAssertEqual(self.installedInputViews.count, 1); + XCTAssertEqual(textInputPlugin.autofillContext.count, 0); +} + +- (void)testAutofillInputViews { + NSMutableDictionary* field1 = self.mutableTemplateCopy; + [field1 setValue:@{ + @"uniqueIdentifier" : @"field1", + @"hints" : @[ @"hint1" ], + @"editingValue" : @{@"text" : @""} + } + forKey:@"autofill"]; + + NSMutableDictionary* field2 = self.mutableTemplateCopy; + [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]; // Find all the FlutterTextInputViews we created. - NSArray* inputFields = [[[[textInputPlugin textInputView] superview] - subviews] - filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@", - [FlutterTextInputView class]]]; + NSArray* inputFields = self.installedInputViews; XCTAssertEqual(inputFields.count, 2); @@ -214,25 +346,30 @@ - (void)testAutofillInputViews { // Verify behavior. OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]); +} - // Clean up. - [engine stopMocking]; - [[[[textInputPlugin textInputView] superview] subviews] - makeObjectsPerformSelector:@selector(removeFromSuperview)]; +-(void)testPasswordAutofillHack { + NSDictionary* config = self.mutableTemplateCopy; + [config setValue:@"YES" forKey:@"obscureText"]; + [self setClientId:123 configuration:config]; + + // Find all the FlutterTextInputViews we created. + NSArray* 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)testAutocorrectionPromptRectAppears { - // Setup test. - id engine = OCMClassMock([FlutterEngine class]); - 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]); - - // Clean up mocks - [engine stopMocking]; } @end From 8f9233eecf4ce337ef3cdaae0e1d1c66a1a6914f Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 27 May 2020 21:48:16 -0700 Subject: [PATCH 02/14] android implementation --- .../systemchannels/TextInputChannel.java | 20 +++++++++++ .../plugin/editing/TextInputPlugin.java | 11 +++++++ .../plugin/editing/TextInputPluginTest.java | 33 +++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index b206e5aa2e36a..db1cc0ad71d46 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -112,6 +112,14 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result textInputMethodHandler.clearClient(); result.success(null); break; + case "TextInput.AutofillContext.commit": + textInputMethodHandler.finishAutofillContext(true); + result.success(null); + break; + case "TextInput.AutofillContext.cancel": + textInputMethodHandler.finishAutofillContext(false); + result.success(null); + break; default: result.notImplemented(); break; @@ -284,6 +292,18 @@ public interface TextInputMethodHandler { */ void requestAutofill(); + /** + * Requests that the {@link AutofillManager} cancel or commit the current + * autofill context, depending on the value of {@code shouldSave}. + * + * The method calls {@link android.view.autofill.AutofillManager#commit()} when {@code shouldSave} + * is true, and calls {@link android.view.autofill.AutofillManager#cancel()} otherwise. + * + * @param shouldSave whether the active autofill service should save the + * current user input for future use. + */ + void finishAutofillContext(boolean shouldSave); + // TODO(mattcarroll): javadoc void setClient(int textInputClientId, @NonNull Configuration configuration); diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 698a44cccfd73..e3c6d5f84f381 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -82,6 +82,17 @@ public void requestAutofill() { notifyViewEntered(); } + @Override + public void finishAutofillContext(boolean shouldSave) { + if (afm == null) + return; + if (shouldSave) { + afm.commit(); + } else { + afm.cancel(); + } + } + @Override public void setClient( int textInputClientId, TextInputChannel.Configuration configuration) { diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index 371961338351b..34d7e4d4eed54 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -35,6 +35,7 @@ import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.JSONMethodCodec; import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.platform.PlatformViewsController; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -70,6 +71,12 @@ private void verifyMethodCall(ByteBuffer buffer, String methodName, String[] exp } } + private static void sendToBinaryMessageHandler(BinaryMessenger.BinaryMessageHandler binaryMessageHandler, String method, Object args) { + MethodCall methodCall = new MethodCall(method, args); + ByteBuffer encodedMethodCall = JSONMethodCodec.INSTANCE.encodeMethodCall(methodCall).position(0); + binaryMessageHandler.onMessage(encodedMethodCall, mock(BinaryMessenger.BinaryReply.class)); + } + @Test public void textInputPlugin_RequestsReattachOnCreation() throws JSONException { // Initialize a general TextInputPlugin. @@ -531,6 +538,32 @@ public void autofill_onProvideVirtualViewStructure_single() { verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0)); } + @Test + public void respondsToInputChannelMessages () { + ArgumentCaptor binaryMessageHandlerCaptor = ArgumentCaptor.forClass(BinaryMessenger.BinaryMessageHandler.class); + DartExecutor mockBinaryMessenger = mock(DartExecutor.class); + TextInputChannel.TextInputMethodHandler mockHandler = mock(TextInputChannel.TextInputMethodHandler.class); + TextInputChannel textInputChannel = new TextInputChannel(mockBinaryMessenger); + + textInputChannel.setTextInputMethodHandler(mockHandler); + + verify(mockBinaryMessenger, times(1)) + .setMessageHandler( + any(String.class), + binaryMessageHandlerCaptor.capture()); + + BinaryMessenger.BinaryMessageHandler binaryMessageHandler = binaryMessageHandlerCaptor.getValue(); + + sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.requestAutofill", null); + verify(mockHandler, times(1)).requestAutofill(); + + sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.AutofillContext.commit", null); + verify(mockHandler, times(1)).finishAutofillContext(true); + + sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.AutofillContext.cancel", null); + verify(mockHandler, times(1)).finishAutofillContext(false); + } + @Implements(InputMethodManager.class) public static class TestImm extends ShadowInputMethodManager { private InputMethodSubtype currentInputMethodSubtype; From a4473adda6dadab98d7ae8fca04fc98fdec4f8f1 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 27 May 2020 22:37:47 -0700 Subject: [PATCH 03/14] formatting --- .../systemchannels/TextInputChannel.java | 12 ++++---- .../plugin/editing/TextInputPlugin.java | 3 +- .../plugin/editing/TextInputPluginTest.java | 22 +++++++------- .../Source/FlutterTextInputPlugin.mm | 30 ++++++++++++++----- .../Source/FlutterTextInputPluginTest.m | 9 +++--- 5 files changed, 47 insertions(+), 29 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index db1cc0ad71d46..dcdabc252482b 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -293,14 +293,14 @@ public interface TextInputMethodHandler { void requestAutofill(); /** - * Requests that the {@link AutofillManager} cancel or commit the current - * autofill context, depending on the value of {@code shouldSave}. + * Requests that the {@link AutofillManager} cancel or commit the current autofill context. * - * The method calls {@link android.view.autofill.AutofillManager#commit()} when {@code shouldSave} - * is true, and calls {@link android.view.autofill.AutofillManager#cancel()} otherwise. + *

The method calls {@link android.view.autofill.AutofillManager#commit()} when {@code + * shouldSave} is true, and calls {@link android.view.autofill.AutofillManager#cancel()} + * otherwise. * - * @param shouldSave whether the active autofill service should save the - * current user input for future use. + * @param shouldSave whether the active autofill service should save the current user input for + * future use. */ void finishAutofillContext(boolean shouldSave); diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index e3c6d5f84f381..c7a178e78ca8b 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -84,8 +84,9 @@ public void requestAutofill() { @Override public void finishAutofillContext(boolean shouldSave) { - if (afm == null) + if (afm == null) { return; + } if (shouldSave) { afm.commit(); } else { diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index 34d7e4d4eed54..78b2426baf1cb 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -35,7 +35,6 @@ import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.JSONMethodCodec; import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.platform.PlatformViewsController; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -71,9 +70,11 @@ private void verifyMethodCall(ByteBuffer buffer, String methodName, String[] exp } } - private static void sendToBinaryMessageHandler(BinaryMessenger.BinaryMessageHandler binaryMessageHandler, String method, Object args) { + private static void sendToBinaryMessageHandler( + BinaryMessenger.BinaryMessageHandler binaryMessageHandler, String method, Object args) { MethodCall methodCall = new MethodCall(method, args); - ByteBuffer encodedMethodCall = JSONMethodCodec.INSTANCE.encodeMethodCall(methodCall).position(0); + ByteBuffer encodedMethodCall = + JSONMethodCodec.INSTANCE.encodeMethodCall(methodCall).position(0); binaryMessageHandler.onMessage(encodedMethodCall, mock(BinaryMessenger.BinaryReply.class)); } @@ -539,20 +540,21 @@ public void autofill_onProvideVirtualViewStructure_single() { } @Test - public void respondsToInputChannelMessages () { - ArgumentCaptor binaryMessageHandlerCaptor = ArgumentCaptor.forClass(BinaryMessenger.BinaryMessageHandler.class); + public void respondsToInputChannelMessages() { + ArgumentCaptor binaryMessageHandlerCaptor = + ArgumentCaptor.forClass(BinaryMessenger.BinaryMessageHandler.class); DartExecutor mockBinaryMessenger = mock(DartExecutor.class); - TextInputChannel.TextInputMethodHandler mockHandler = mock(TextInputChannel.TextInputMethodHandler.class); + TextInputChannel.TextInputMethodHandler mockHandler = + mock(TextInputChannel.TextInputMethodHandler.class); TextInputChannel textInputChannel = new TextInputChannel(mockBinaryMessenger); textInputChannel.setTextInputMethodHandler(mockHandler); verify(mockBinaryMessenger, times(1)) - .setMessageHandler( - any(String.class), - binaryMessageHandlerCaptor.capture()); + .setMessageHandler(any(String.class), binaryMessageHandlerCaptor.capture()); - BinaryMessenger.BinaryMessageHandler binaryMessageHandler = binaryMessageHandlerCaptor.getValue(); + BinaryMessenger.BinaryMessageHandler binaryMessageHandler = + binaryMessageHandlerCaptor.getValue(); sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.requestAutofill", null); verify(mockHandler, times(1)).requestAutofill(); diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 2bb920b1921bf..f0c646cbaa565 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -220,8 +220,18 @@ static UITextContentType ToUITextContentType(NSArray* hints) { return [dictionary[@"obscureText"] boolValue] ? @"password" : nil; } -// We refer to password/username autofill, one-time-code autofill, -// and predictive text for contact information etc. as autofill. +// There're 2 types of autofills on native iOS: +// - Regular autofill, includes contact information autofill and +// one-time-code autofill, takes place in the form of predictive +// text in the quick type bar. This type of autofill does not save +// user input. +// - Password autofill, includes automatic strong password and regular +// password autofill. The former happens automatically when a +// "new password" field is detected, and only that password field +// will be filled. The latter appears in the quick type bar when +// an eligible input field becomes the first responder, and may +// fill both the username and the password fields. iOS will attempt +// to save user input for both kinds of password fields. typedef NS_ENUM(NSInteger, FlutterAutofillType) { FlutterAutofillTypeNone, FlutterAutofillTypeRegular, @@ -238,7 +248,7 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { // for multiple input fields when there's a password input field among them. // When the configuration contains more than 1 field, we consider this // autofill group password related. - if (isSecureTextEntry || (fields && [fields count] > 1)) + if (isSecureTextEntry || (fields && fields.count > 1)) return FlutterAutofillTypePassword; if (uniqueIdFromDictionary(configuration) == nil) { @@ -324,9 +334,9 @@ - (BOOL)isEqualTo:(FlutterTextRange*)other { // selectors it can't respond to to a shared UITextField instance. // // Relevant API docs claim that password autofill supports any custom view -// that adopts the UITextInput protocol, auto strong password seems to -// currently only support UITextFields and saving username/password -// for autofill only supports UITextFields and UITextViews, as of iOS 13.5. +// that adopts the UITextInput protocol, automatic strong password seems to +// currently only support UITextFields, and password saving only supports +// UITextFields and UITextViews, as of iOS 13.5. @interface FlutterSecureTextInputView : FlutterTextInputView @property(class, nonatomic, assign, readonly) UITextField* textField; @end @@ -435,11 +445,13 @@ - (void)configureWithDictionary:(NSDictionary*)configuration { ? UITextAutocorrectionTypeNo : UITextAutocorrectionTypeDefault; if (@available(iOS 10.0, *)) { + _autofillId = uniqueIdFromDictionary(configuration); if (autofill == nil) { self.textContentType = @""; } else { self.textContentType = ToUITextContentType(autofill[@"hints"]); [self setTextInputState:autofill[@"editingValue"]]; + NSAssert(_autofillId, @"The autofill configuration must contain an autofill id"); // The input field needs to be visible for the system autofill // to find it. self.frame = CGRectMake(0, 0, 1, 1); @@ -524,7 +536,8 @@ - (NSRange)clampSelection:(NSRange)range forText:(NSString*)text { - (BOOL)canBecomeFirstResponder { // Only the currently focused input field can // become the first responder. This prevents iOS - // from changing focus by itself. + // from changing focus by itself (the framework + // focus will be out of sync if that happens). return _textInputClient != 0; } @@ -1132,11 +1145,12 @@ - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field } inputView.textInputDelegate = _textInputDelegate; - inputView.autofillId = autofillId; [inputView configureWithDictionary:field]; return inputView; } +// Remove every installed input field, unless it's in the current autofill +// context. - (void)removeNonPasswordAutofillFields:(BOOL)removeNonAutofillField { UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m index 7a9424258e4f0..fe711ff7b82b2 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m @@ -258,6 +258,7 @@ - (void)testAutofillContext { oldContext = textInputPlugin.autofillContext; [self setClientId:200 configuration:config]; + // Reuse the input view instance from the last time. XCTAssertEqual(textInputPlugin.autofillContext.count, 4); XCTAssertEqual(self.installedInputViews.count, 4); @@ -348,16 +349,16 @@ - (void)testAutofillInputViews { OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]); } --(void)testPasswordAutofillHack { +- (void)testPasswordAutofillHack { NSDictionary* config = self.mutableTemplateCopy; [config setValue:@"YES" forKey:@"obscureText"]; [self setClientId:123 configuration:config]; - + // Find all the FlutterTextInputViews we created. NSArray* 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. From 652ef5eb2d7d35248d2589d9f51f20c0f660830e Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 27 May 2020 23:24:12 -0700 Subject: [PATCH 04/14] fix api availability --- .../android/io/flutter/plugin/editing/TextInputPlugin.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index c7a178e78ca8b..1aea8fbb279e3 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -84,7 +84,7 @@ public void requestAutofill() { @Override public void finishAutofillContext(boolean shouldSave) { - if (afm == null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null) { return; } if (shouldSave) { From 6499cf537d9b9dde053ad5e7082a10cfbbbb5e07 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Thu, 28 May 2020 10:12:53 -0700 Subject: [PATCH 05/14] position(0) -> rewind --- .../test/io/flutter/plugin/editing/TextInputPluginTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index 78b2426baf1cb..e0630823a4e9b 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -73,8 +73,7 @@ private void verifyMethodCall(ByteBuffer buffer, String methodName, String[] exp private static void sendToBinaryMessageHandler( BinaryMessenger.BinaryMessageHandler binaryMessageHandler, String method, Object args) { MethodCall methodCall = new MethodCall(method, args); - ByteBuffer encodedMethodCall = - JSONMethodCodec.INSTANCE.encodeMethodCall(methodCall).position(0); + ByteBuffer encodedMethodCall = JSONMethodCodec.INSTANCE.encodeMethodCall(methodCall).flip(); binaryMessageHandler.onMessage(encodedMethodCall, mock(BinaryMessenger.BinaryReply.class)); } From 465cc6b63c472f8ad86250102cf349fc5437e2fb Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 8 Jul 2020 02:16:51 -0700 Subject: [PATCH 06/14] update --- .../Source/FlutterTextInputPlugin.mm | 148 +++++++++++------- .../Source/FlutterTextInputPluginTest.m | 14 +- 2 files changed, 96 insertions(+), 66 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 3aaf47461e499..1dff1aaf5cc5b 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -209,7 +209,7 @@ static UITextContentType ToUITextContentType(NSArray* hints) { return hints[0]; } -static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) { +static NSString* autofillIdFromDictionary(NSDictionary* dictionary) { NSDictionary* autofill = dictionary[@"autofill"]; if (autofill) { return autofill[@"uniqueIdentifier"]; @@ -228,11 +228,14 @@ static UITextContentType ToUITextContentType(NSArray* hints) { // - Password autofill, includes automatic strong password and regular // password autofill. The former happens automatically when a // "new password" field is detected, and only that password field -// will be filled. The latter appears in the quick type bar when +// will be populated. The latter appears in the quick type bar when // an eligible input field becomes the first responder, and may // fill both the username and the password fields. iOS will attempt // to save user input for both kinds of password fields. typedef NS_ENUM(NSInteger, FlutterAutofillType) { + // The field does not have autofillable content. Additionally if + // the field is currently in the autofill context, it will be + // removed from the context without triggering autofill save. FlutterAutofillTypeNone, FlutterAutofillTypeRegular, FlutterAutofillTypePassword, @@ -251,7 +254,7 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { if (isSecureTextEntry || (fields && fields.count > 1)) return FlutterAutofillTypePassword; - if (uniqueIdFromDictionary(configuration) == nil) { + if (autofillIdFromDictionary(configuration) == nil) { return FlutterAutofillTypeNone; } @@ -270,6 +273,8 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { return FlutterAutofillTypePassword; } } + + return [contentType isEqualToString:@""] ? FlutterAutofillTypeNone : FlutterAutofillTypeRegular; } return FlutterAutofillTypeNone; @@ -445,7 +450,7 @@ - (void)configureWithDictionary:(NSDictionary*)configuration { ? UITextAutocorrectionTypeNo : UITextAutocorrectionTypeDefault; if (@available(iOS 10.0, *)) { - _autofillId = uniqueIdFromDictionary(configuration); + _autofillId = autofillIdFromDictionary(configuration); if (autofill == nil) { self.textContentType = @""; } else { @@ -984,7 +989,7 @@ - (BOOL)accessibilityElementsHidden { @end @interface FlutterTextInputPlugin () -@property(nonatomic, retain) FlutterTextInputView* singleInputView; +@property(nonatomic, retain) FlutterTextInputView* reusableInputView; // The current password-autofillable input fields that have yet to be saved. @property(nonatomic, readonly) @@ -1000,11 +1005,11 @@ - (instancetype)init { self = [super init]; if (self) { - _singleInputView = [[FlutterTextInputView alloc] init]; - _singleInputView.secureTextEntry = NO; + _reusableInputView = [[FlutterTextInputView alloc] init]; + _reusableInputView.secureTextEntry = NO; _autofillContext = [[NSMutableDictionary alloc] init]; - _activeView = _singleInputView; + _activeView = _reusableInputView; } return self; @@ -1012,7 +1017,7 @@ - (instancetype)init { - (void)dealloc { [self hideTextInput]; - [_singleInputView release]; + [_reusableInputView release]; [_autofillContext release]; [super dealloc]; @@ -1070,17 +1075,8 @@ - (void)hideTextInput { - (void)triggerAutofillSave:(BOOL)saveEntries { [self hideTextInput]; - - if (!saveEntries) { - [_autofillContext - enumerateKeysAndObjectsUsingBlock:^( - NSString* _Nonnull key, FlutterTextInputView* _Nonnull obj, BOOL* _Nonnull stop) { - [obj.text setString:@""]; - }]; - } - [_autofillContext removeAllObjects]; - [self removeNonPasswordAutofillFields:NO]; + [self cleanUpViewHierarchy:NO clearText:!saveEntries]; UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; if (_activeView.window != keyWindow) { [keyWindow addSubview:_activeView]; @@ -1088,57 +1084,83 @@ - (void)triggerAutofillSave:(BOOL)saveEntries { } - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration { - UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; - NSArray* fields = configuration[@"fields"]; - NSString* clientUniqueId = uniqueIdFromDictionary(configuration); - - [self removeNonPasswordAutofillFields:YES]; - FlutterAutofillType autofillType = autofillTypeOf(configuration); - - switch (autofillType) { + switch (autofillTypeOf(configuration)) { case FlutterAutofillTypeNone: - _activeView = _singleInputView; - - [_activeView configureWithDictionary:configuration]; - if (_activeView.window != keyWindow) { - [keyWindow addSubview:_activeView]; - } - break; case FlutterAutofillTypeRegular: - _activeView = [self updateAutofillableView:(fields ? fields : @[ configuration ]) - isPasswordAutofill:NO - focusedId:clientUniqueId]; + _activeView = [self updateAndShowReusableInputView:configuration]; break; case FlutterAutofillTypePassword: - _activeView = [self updateAutofillableView:(fields ? fields : @[ configuration ]) - isPasswordAutofill:YES - focusedId:clientUniqueId]; + _activeView = [self updateAndShowPasswordAutofillViews:configuration[@"fields"] + focusedField:configuration]; break; } - + + [self cleanUpViewHierarchy:NO clearText:YES]; [_activeView setTextInputClient:client]; [_activeView reloadInputViews]; } -- (FlutterTextInputView*)updateAutofillableView:(NSArray*)fields - isPasswordAutofill:(BOOL)needsPasswordAutofill - focusedId:(NSString*)focusedId { +// Updates and made visible an input field that has nothing to do with password input. +// Also updates the current autofill context. +- (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configuration { + // It's possible that the configuration of this non-autofillable input view has + // an autofill configuration without hints. If it does, remove it from the context. + NSString* autofillId = autofillIdFromDictionary(configuration); + if (autofillId) { + [_autofillContext removeObjectForKey:autofillId]; + } + + [_reusableInputView configureWithDictionary:configuration]; + + UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; + if (_reusableInputView.window != keyWindow) { + [keyWindow addSubview:_reusableInputView]; + } + + NSArray* fields = configuration[@"fields"]; + if (fields) { + for (NSDictionary* field in fields) { + NSString* autofillId = autofillIdFromDictionary(field); + if (autofillId && autofillTypeOf(field) == FlutterAutofillTypeNone) { + [_autofillContext removeObjectForKey:autofillId]; + } + } + } + return _reusableInputView; +} + +- (FlutterTextInputView*)updateAndShowPasswordAutofillViews:(NSArray*)fields + focusedField:(NSDictionary*)focusedField { FlutterTextInputView* focused = nil; + NSString* focusedId = autofillIdFromDictionary(focusedField); + NSAssert(focusedId, @"autofillId must not be null for the focused field: %@", focusedField); + + if (!fields) { + // DO NOT push the current autofillable input fields to the context even + // if it's password-related, because it is not in an autofill group. + focused = [self getOrCreateAutofillableView:focusedField isPasswordAutofill:YES]; + [_autofillContext removeObjectForKey:focusedId]; + return focused; + } for (NSDictionary* field in fields) { - NSString* autofillId = uniqueIdFromDictionary(field); + NSString* autofillId = autofillIdFromDictionary(field); NSAssert(autofillId, @"autofillId must not be null for field: %@", field); - FlutterTextInputView* newInputView = [self getOrCreateAutofillableView:field - isPasswordAutofill:needsPasswordAutofill]; - if ([focusedId isEqualToString:autofillId]) { - focused = newInputView; + BOOL hasHints = autofillTypeOf(field) != FlutterAutofillTypeNone; + BOOL isFocused = [focusedId isEqualToString:autofillId]; + + if (isFocused) { + focused = [self getOrCreateAutofillableView:field isPasswordAutofill:YES]; } - // Push the current autofillable input fields to the context if they're - // password-related. - if (needsPasswordAutofill) { - _autofillContext[autofillId] = newInputView; + if (hasHints) { + // Push the current input field to the context if it has hints. + _autofillContext[autofillId] = + isFocused ? focused : [self getOrCreateAutofillableView:field isPasswordAutofill:YES]; + } else { + // Mark for deletion; + [_autofillContext removeObjectForKey:autofillId]; } } @@ -1146,9 +1168,14 @@ - (FlutterTextInputView*)updateAutofillableView:(NSArray*)fields return focused; } +// Returns a new non-reusable input view (and put it into the view hierarchy), or get the +// view from the current autofill context, if an input view with the same autofill id +// already exists in the context. +// This is generally used for input fields that are password-related (UIKit tracks these +// views for password autofill so they should not be reused for a differnet type of views). - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field isPasswordAutofill:(BOOL)needsPasswordAutofill { - NSString* autofillId = uniqueIdFromDictionary(field); + NSString* autofillId = autofillIdFromDictionary(field); UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; FlutterTextInputView* inputView = _autofillContext[autofillId]; @@ -1164,16 +1191,19 @@ - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field return inputView; } -// Remove every installed input field, unless it's in the current autofill -// context. -- (void)removeNonPasswordAutofillFields:(BOOL)removeNonAutofillField { +// Removes every installed input field, unless it's in the current autofill +// context. May remove the active view too if includeActiveView is YES. +- (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText { UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; for (UIView* view in keyWindow.subviews) { if ([view isKindOfClass:[FlutterTextInputView class]] && - (removeNonAutofillField || view != _singleInputView)) { + (includeActiveView || view != _activeView)) { FlutterTextInputView* inputView = (FlutterTextInputView*)view; - if (!_autofillContext[inputView.autofillId]) { + if (_autofillContext[inputView.autofillId] != view) { + if (clearText) { + inputView.text.string = @""; + } [view removeFromSuperview]; } } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m index b20d745fc2ca3..033f261bec356 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m @@ -16,7 +16,7 @@ - (void)setTextInputState:(NSDictionary*)state; @end @interface FlutterTextInputPlugin () -@property(nonatomic, retain) FlutterTextInputView* singleInputView; +@property(nonatomic, retain) FlutterTextInputView* reusableInputView; @property(nonatomic, readonly) NSMutableDictionary* autofillContext; @end @@ -238,21 +238,21 @@ - (void)testAutofillContext { XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]); } - // Switch to a password field that has no contentType, nor in an AutofillGroup. + // Switch to a password field that has no contentType and is not in an AutofillGroup. config = self.mutableTemplateCopy; [config setValue:@"YES" forKey:@"obscureText"]; oldContext = textInputPlugin.autofillContext; [self setClientId:124 configuration:config]; - XCTAssertEqual(textInputPlugin.autofillContext.count, 4); + XCTAssertEqual(textInputPlugin.autofillContext.count, 3); 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 changes. + // The active view should change. XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); // Switch to a similar password field, the previous field should be reused. @@ -260,7 +260,7 @@ - (void)testAutofillContext { [self setClientId:200 configuration:config]; // Reuse the input view instance from the last time. - XCTAssertEqual(textInputPlugin.autofillContext.count, 4); + XCTAssertEqual(textInputPlugin.autofillContext.count, 3); XCTAssertEqual(self.installedInputViews.count, 4); // Old autofill input fields are still installed and reused. @@ -304,12 +304,12 @@ - (void)testCommitAutofillContext { // Now switch to a regular field (no autofill). [self setClientId:125 configuration:self.mutableTemplateCopy]; - XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.singleInputView); + XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); XCTAssertEqual(self.installedInputViews.count, 3); XCTAssertEqual(textInputPlugin.autofillContext.count, 2); [self commitAutofillContext]; - XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.singleInputView); + XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); XCTAssertEqual(self.installedInputViews.count, 1); XCTAssertEqual(textInputPlugin.autofillContext.count, 0); } From 3eb250ba4801d112800b49faad688349bc1e3631 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 8 Jul 2020 02:55:48 -0700 Subject: [PATCH 07/14] update --- .../Source/FlutterTextInputPlugin.mm | 55 ++++++++----------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 1dff1aaf5cc5b..610c4a99df3c3 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -1008,7 +1008,6 @@ - (instancetype)init { _reusableInputView = [[FlutterTextInputView alloc] init]; _reusableInputView.secureTextEntry = NO; _autofillContext = [[NSMutableDictionary alloc] init]; - _activeView = _reusableInputView; } @@ -1057,15 +1056,8 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } - (void)showTextInput { - 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; - - if (_activeView.window != keyWindow) { - [keyWindow addSubview:_activeView]; - } + [self addToKeyWindowIfNeeded:_activeView]; [_activeView becomeFirstResponder]; } @@ -1076,11 +1068,8 @@ - (void)hideTextInput { - (void)triggerAutofillSave:(BOOL)saveEntries { [self hideTextInput]; [_autofillContext removeAllObjects]; - [self cleanUpViewHierarchy:NO clearText:!saveEntries]; - UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; - if (_activeView.window != keyWindow) { - [keyWindow addSubview:_activeView]; - } + [self cleanUpViewHierarchy:YES clearText:!saveEntries]; + [self addToKeyWindowIfNeeded:_activeView]; } - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration { @@ -1111,19 +1100,12 @@ - (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configura } [_reusableInputView configureWithDictionary:configuration]; + [self addToKeyWindowIfNeeded:_reusableInputView]; - UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; - if (_reusableInputView.window != keyWindow) { - [keyWindow addSubview:_reusableInputView]; - } - - NSArray* fields = configuration[@"fields"]; - if (fields) { - for (NSDictionary* field in fields) { - NSString* autofillId = autofillIdFromDictionary(field); - if (autofillId && autofillTypeOf(field) == FlutterAutofillTypeNone) { - [_autofillContext removeObjectForKey:autofillId]; - } + for (NSDictionary* field in configuration[@"fields"]) { + NSString* autofillId = autofillIdFromDictionary(field); + if (autofillId && autofillTypeOf(field) == FlutterAutofillTypeNone) { + [_autofillContext removeObjectForKey:autofillId]; } } return _reusableInputView; @@ -1140,7 +1122,6 @@ - (FlutterTextInputView*)updateAndShowPasswordAutofillViews:(NSArray*)fields // if it's password-related, because it is not in an autofill group. focused = [self getOrCreateAutofillableView:focusedField isPasswordAutofill:YES]; [_autofillContext removeObjectForKey:focusedId]; - return focused; } for (NSDictionary* field in fields) { @@ -1176,14 +1157,12 @@ - (FlutterTextInputView*)updateAndShowPasswordAutofillViews:(NSArray*)fields - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field isPasswordAutofill:(BOOL)needsPasswordAutofill { NSString* autofillId = autofillIdFromDictionary(field); - UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; - FlutterTextInputView* inputView = _autofillContext[autofillId]; if (!inputView) { inputView = needsPasswordAutofill ? [FlutterSecureTextInputView alloc] : [FlutterTextInputView alloc]; inputView = [[inputView init] autorelease]; - [keyWindow addSubview:inputView]; + [self addToKeyWindowIfNeeded:inputView]; } inputView.textInputDelegate = _textInputDelegate; @@ -1195,7 +1174,10 @@ - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field // context. May remove the active view too if includeActiveView is YES. - (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText { 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"); + for (UIView* view in keyWindow.subviews) { if ([view isKindOfClass:[FlutterTextInputView class]] && (includeActiveView || view != _activeView)) { @@ -1210,6 +1192,17 @@ - (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText { } } +- (void)addToKeyWindowIfNeeded: (FlutterTextInputView*)inputView { + 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"); + + if (inputView.window != keyWindow) { + [keyWindow addSubview:inputView]; + } +} + - (void)setTextInputEditingState:(NSDictionary*)state { if ([_activeView setTextInputState:state]) { [_activeView updateEditingState]; From 2127bc5d72d934648b6f9a5b6b665faaed71445a Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 8 Jul 2020 13:07:14 -0700 Subject: [PATCH 08/14] autofill visibility --- .../Source/FlutterTextInputPlugin.mm | 93 +++++++++++++----- .../Source/FlutterTextInputPluginTest.m | 95 +++++++++++++++---- 2 files changed, 146 insertions(+), 42 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 610c4a99df3c3..39fd480b3a7e6 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -241,39 +241,48 @@ typedef NS_ENUM(NSInteger, FlutterAutofillType) { FlutterAutofillTypePassword, }; -static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { - NSArray* fields = configuration[@"fields"]; - BOOL isSecureTextEntry = [configuration[@"obscureText"] boolValue]; - +static BOOL isFieldPasswordRelated(NSDictionary* configuration) { if (@available(iOS 10.0, *)) { - // Currently iOS only fills the focused field unless it's a password - // autofill. As an optimization, the framework will only send configurations - // for multiple input fields when there's a password input field among them. - // When the configuration contains more than 1 field, we consider this - // autofill group password related. - if (isSecureTextEntry || (fields && fields.count > 1)) - return FlutterAutofillTypePassword; + BOOL isSecureTextEntry = [configuration[@"obscureText"] boolValue]; + if (isSecureTextEntry) + return YES; - if (autofillIdFromDictionary(configuration) == nil) { - return FlutterAutofillTypeNone; + if (!autofillIdFromDictionary(configuration)) { + return NO; } - NSDictionary* autofill = configuration[@"autofill"]; UITextContentType contentType = ToUITextContentType(autofill[@"hints"]); if (@available(iOS 11.0, *)) { if ([contentType isEqualToString:UITextContentTypePassword] || [contentType isEqualToString:UITextContentTypeUsername]) { - return FlutterAutofillTypePassword; + return YES; } } if (@available(iOS 12.0, *)) { if ([contentType isEqualToString:UITextContentTypeNewPassword]) { - return FlutterAutofillTypePassword; + return YES; } } + } + return NO; +} + +static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { + for (NSDictionary* field in configuration[@"fields"]) { + if (isFieldPasswordRelated(field)) { + return FlutterAutofillTypePassword; + } + } + if (isFieldPasswordRelated(configuration)) { + return FlutterAutofillTypePassword; + } + + if (@available(iOS 10.0, *)) { + NSDictionary* autofill = configuration[@"autofill"]; + UITextContentType contentType = ToUITextContentType(autofill[@"hints"]); return [contentType isEqualToString:@""] ? FlutterAutofillTypeNone : FlutterAutofillTypeRegular; } @@ -375,6 +384,7 @@ - (void)forwardInvocation:(NSInvocation*)anInvocation { @interface FlutterTextInputView () @property(nonatomic, copy) NSString* autofillId; +@property(nonatomic) BOOL isVisibleToAutofill; @end @implementation FlutterTextInputView { @@ -459,8 +469,8 @@ - (void)configureWithDictionary:(NSDictionary*)configuration { NSAssert(_autofillId, @"The autofill configuration must contain an autofill id"); // The input field needs to be visible for the system autofill // to find it. - self.frame = CGRectMake(0, 0, 1, 1); } + self.isVisibleToAutofill = autofill || _secureTextEntry; } } @@ -535,6 +545,14 @@ - (NSRange)clampSelection:(NSRange)range forText:(NSString*)text { return NSMakeRange(start, length); } +- (BOOL)isVisibleToAutofill { + return self.frame.size.width > 0 && self.frame.size.height > 0; +} + +- (void)setIsVisibleToAutofill:(BOOL)isVisibleToAutofill { + self.frame = isVisibleToAutofill ? CGRectMake(0, 0, 1, 1) : CGRectZero; +} + #pragma mark - UIResponder Overrides - (BOOL)canBecomeFirstResponder { @@ -1067,12 +1085,25 @@ - (void)hideTextInput { - (void)triggerAutofillSave:(BOOL)saveEntries { [self hideTextInput]; - [_autofillContext removeAllObjects]; + + if (saveEntries) { + // Make all the input fields in the autofill context visible, + // then remove them to trigger autofill save. + [self cleanUpViewHierarchy:YES clearText:YES]; + [_autofillContext removeAllObjects]; + [self changeInputViewsAutofillVisibility:YES]; + } else { + [_autofillContext removeAllObjects]; + } + [self cleanUpViewHierarchy:YES clearText:!saveEntries]; [self addToKeyWindowIfNeeded:_activeView]; } - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration { + // Hide all input views from autofill, only make those in the new configuration visible + // to autofill. + [self changeInputViewsAutofillVisibility:NO]; switch (autofillTypeOf(configuration)) { case FlutterAutofillTypeNone: case FlutterAutofillTypeRegular: @@ -1083,13 +1114,15 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur focusedField:configuration]; break; } - + + // Clean up views that should no longer be in the view hierarchy according to the + // updated autofill context. [self cleanUpViewHierarchy:NO clearText:YES]; [_activeView setTextInputClient:client]; [_activeView reloadInputViews]; } -// Updates and made visible an input field that has nothing to do with password input. +// Updates and shows an input field that is not password related. // Also updates the current autofill context. - (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configuration { // It's possible that the configuration of this non-autofillable input view has @@ -1177,7 +1210,7 @@ - (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText { NSAssert(keyWindow != nullptr, @"The application must have a key window since the keyboard client " @"must be part of the responder chain to function"); - + for (UIView* view in keyWindow.subviews) { if ([view isKindOfClass:[FlutterTextInputView class]] && (includeActiveView || view != _activeView)) { @@ -1192,12 +1225,26 @@ - (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText { } } -- (void)addToKeyWindowIfNeeded: (FlutterTextInputView*)inputView { +- (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility { 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"); - + + for (UIView* view in keyWindow.subviews) { + if ([view isKindOfClass:[FlutterTextInputView class]]) { + FlutterTextInputView* inputView = (FlutterTextInputView*)view; + inputView.isVisibleToAutofill = newVisibility; + } + } +} + +- (void)addToKeyWindowIfNeeded:(FlutterTextInputView*)inputView { + 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"); + if (inputView.window != keyWindow) { [keyWindow addSubview:inputView]; } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m index 033f261bec356..bb0be615cb540 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m @@ -12,11 +12,14 @@ @interface FlutterTextInputView () @property(nonatomic, copy) NSString* autofillId; + - (void)setTextInputState:(NSDictionary*)state; +- (BOOL)isVisibleToAutofill; @end @interface FlutterTextInputPlugin () @property(nonatomic, retain) FlutterTextInputView* reusableInputView; +@property(nonatomic, assign) FlutterTextInputView* activeView; @property(nonatomic, readonly) NSMutableDictionary* autofillContext; @end @@ -26,6 +29,7 @@ @interface FlutterTextInputPluginTest : XCTestCase @implementation FlutterTextInputPluginTest { NSDictionary* _template; + NSDictionary* _passwordTemplate; id engine; FlutterTextInputPlugin* textInputPlugin; } @@ -55,13 +59,21 @@ - (void)setClientId:(int)clientId configuration:(NSDictionary*)config { }]; } -- (void)commitAutofillContext { +- (void)commitAutofillContextAndVerify { FlutterMethodCall* methodCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.AutofillContext.commit" arguments:nil]; [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); } - (NSMutableDictionary*)mutableTemplateCopy { @@ -80,6 +92,22 @@ - (NSMutableDictionary*)mutableTemplateCopy { return [_template mutableCopy]; } +- (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*)installedInputViews { UIWindow* keyWindow = [[[UIApplication sharedApplication] windows] @@ -91,6 +119,13 @@ - (NSMutableDictionary*)mutableTemplateCopy { [FlutterTextInputView class]]]; } +- (NSArray*)viewsVisibleToAutofill { + return [self.installedInputViews + filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisibleToAutofill == YES"]]; +} + +#pragma mark - Tests + - (void)testSecureInput { NSDictionary* config = self.mutableTemplateCopy; [config setValue:@"YES" forKey:@"obscureText"]; @@ -197,7 +232,7 @@ - (void)testAutofillContext { } forKey:@"autofill"]; - NSMutableDictionary* field2 = self.mutableTemplateCopy; + NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy; [field2 setValue:@{ @"uniqueIdentifier" : @"field2", @"hints" : @[ @"hint2" ], @@ -209,13 +244,14 @@ - (void)testAutofillContext { [config setValue:@[ field1, field2 ] forKey:@"fields"]; [self setClientId:123 configuration:config]; + XCTAssertEqual(self.viewsVisibleToAutofill.count, 2); XCTAssertEqual(textInputPlugin.autofillContext.count, 2); XCTAssertEqual(self.installedInputViews.count, 2); XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); // The configuration changes. - NSMutableDictionary* field3 = self.mutableTemplateCopy; + NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy; [field3 setValue:@{ @"uniqueIdentifier" : @"field3", @"hints" : @[ @"hint3" ], @@ -229,6 +265,7 @@ - (void)testAutofillContext { [self setClientId:123 configuration:config]; + XCTAssertEqual(self.viewsVisibleToAutofill.count, 2); XCTAssertEqual(textInputPlugin.autofillContext.count, 3); XCTAssertEqual(self.installedInputViews.count, 3); XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); @@ -239,12 +276,12 @@ - (void)testAutofillContext { } // Switch to a password field that has no contentType and is not in an AutofillGroup. - config = self.mutableTemplateCopy; - [config setValue:@"YES" forKey:@"obscureText"]; + config = self.mutablePasswordTemplateCopy; oldContext = textInputPlugin.autofillContext; [self setClientId:124 configuration:config]; + XCTAssertEqual(self.viewsVisibleToAutofill.count, 1); XCTAssertEqual(textInputPlugin.autofillContext.count, 3); XCTAssertEqual(self.installedInputViews.count, 4); @@ -260,6 +297,7 @@ - (void)testAutofillContext { [self setClientId:200 configuration:config]; // Reuse the input view instance from the last time. + XCTAssertEqual(self.viewsVisibleToAutofill.count, 1); XCTAssertEqual(textInputPlugin.autofillContext.count, 3); XCTAssertEqual(self.installedInputViews.count, 4); @@ -279,7 +317,7 @@ - (void)testCommitAutofillContext { } forKey:@"autofill"]; - NSMutableDictionary* field2 = self.mutableTemplateCopy; + NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy; [field2 setValue:@{ @"uniqueIdentifier" : @"field2", @"hints" : @[ @"hint2" ], @@ -287,31 +325,48 @@ - (void)testCommitAutofillContext { } 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 commitAutofillContext]; + [self commitAutofillContextAndVerify]; + XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); + // 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); + XCTAssertEqual(self.installedInputViews.count, 3); + XCTAssertEqual(textInputPlugin.autofillContext.count, 2); 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); - - [self setClientId:124 configuration:config]; - // Now switch to a regular field (no autofill). - [self setClientId:125 configuration:self.mutableTemplateCopy]; + [self commitAutofillContextAndVerify]; XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); - XCTAssertEqual(self.installedInputViews.count, 3); - XCTAssertEqual(textInputPlugin.autofillContext.count, 2); - [self commitAutofillContext]; + // 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. XCTAssertEqual(self.installedInputViews.count, 1); XCTAssertEqual(textInputPlugin.autofillContext.count, 0); + + [self commitAutofillContextAndVerify]; + XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); } - (void)testAutofillInputViews { @@ -323,7 +378,7 @@ - (void)testAutofillInputViews { } forKey:@"autofill"]; - NSMutableDictionary* field2 = self.mutableTemplateCopy; + NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy; [field2 setValue:@{ @"uniqueIdentifier" : @"field2", @"hints" : @[ @"hint2" ], @@ -339,7 +394,9 @@ - (void)testAutofillInputViews { // Find all the FlutterTextInputViews we created. NSArray* 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]; From d3494c9f8ddbcf85441727f6bae4ad03516a1cd7 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 8 Jul 2020 18:45:06 -0700 Subject: [PATCH 09/14] fix android tests --- .../systemchannels/TextInputChannel.java | 8 +--- .../plugin/editing/TextInputPluginTest.java | 9 +++-- .../Source/FlutterTextInputPlugin.mm | 40 ++++++++++++------- .../Source/FlutterTextInputPluginTest.m | 6 +-- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index dcdabc252482b..8cb778daf1167 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -112,12 +112,8 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result textInputMethodHandler.clearClient(); result.success(null); break; - case "TextInput.AutofillContext.commit": - textInputMethodHandler.finishAutofillContext(true); - result.success(null); - break; - case "TextInput.AutofillContext.cancel": - textInputMethodHandler.finishAutofillContext(false); + case "TextInput.finishAutofillContext": + textInputMethodHandler.finishAutofillContext((boolean) args); result.success(null); break; default: diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index 7c62cad4e88d6..bd4eb6f4b32ec 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -73,8 +73,9 @@ private void verifyMethodCall(ByteBuffer buffer, String methodName, String[] exp private static void sendToBinaryMessageHandler( BinaryMessenger.BinaryMessageHandler binaryMessageHandler, String method, Object args) { MethodCall methodCall = new MethodCall(method, args); - ByteBuffer encodedMethodCall = JSONMethodCodec.INSTANCE.encodeMethodCall(methodCall).flip(); - binaryMessageHandler.onMessage(encodedMethodCall, mock(BinaryMessenger.BinaryReply.class)); + ByteBuffer encodedMethodCall = JSONMethodCodec.INSTANCE.encodeMethodCall(methodCall); + binaryMessageHandler.onMessage( + (ByteBuffer) encodedMethodCall.flip(), mock(BinaryMessenger.BinaryReply.class)); } @Test @@ -558,10 +559,10 @@ public void respondsToInputChannelMessages() { sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.requestAutofill", null); verify(mockHandler, times(1)).requestAutofill(); - sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.AutofillContext.commit", null); + sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.finishAutofillContext", true); verify(mockHandler, times(1)).finishAutofillContext(true); - sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.AutofillContext.cancel", null); + sendToBinaryMessageHandler(binaryMessageHandler, "TextInput.finishAutofillContext", false); verify(mockHandler, times(1)).finishAutofillContext(false); } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 39fd480b3a7e6..8ede4ca6a146f 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -460,7 +460,7 @@ - (void)configureWithDictionary:(NSDictionary*)configuration { ? UITextAutocorrectionTypeNo : UITextAutocorrectionTypeDefault; if (@available(iOS 10.0, *)) { - _autofillId = autofillIdFromDictionary(configuration); + self.autofillId = autofillIdFromDictionary(configuration); if (autofill == nil) { self.textContentType = @""; } else { @@ -1062,8 +1062,8 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } else if ([method isEqualToString:@"TextInput.clearClient"]) { [self clearTextInputClient]; result(nil); - } else if ([method isEqualToString:@"TextInput.AutofillContext.commit"]) { - [self triggerAutofillSave:YES]; + } else if ([method isEqualToString:@"TextInput.finishAutofillContext"]) { + [self triggerAutofillSave:args]; result(nil); } else if ([method isEqualToString:@"TextInput.AutofillContext.clear"]) { [self triggerAutofillSave:NO]; @@ -1106,12 +1106,17 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur [self changeInputViewsAutofillVisibility:NO]; switch (autofillTypeOf(configuration)) { case FlutterAutofillTypeNone: - case FlutterAutofillTypeRegular: _activeView = [self updateAndShowReusableInputView:configuration]; break; + case FlutterAutofillTypeRegular: + _activeView = [self updateAndShowAutofillViews:nil + focusedField:configuration + isPasswordRelated:NO]; + break; case FlutterAutofillTypePassword: - _activeView = [self updateAndShowPasswordAutofillViews:configuration[@"fields"] - focusedField:configuration]; + _activeView = [self updateAndShowAutofillViews:configuration[@"fields"] + focusedField:configuration + isPasswordRelated:YES]; break; } @@ -1122,7 +1127,9 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur [_activeView reloadInputViews]; } -// Updates and shows an input field that is not password related. +// Updates and shows an input field that is not password related and has no autofill +// hints. This method reconfigure an existing instance of input field instead of +// creating a new one. // Also updates the current autofill context. - (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configuration { // It's possible that the configuration of this non-autofillable input view has @@ -1134,6 +1141,7 @@ - (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configura [_reusableInputView configureWithDictionary:configuration]; [self addToKeyWindowIfNeeded:_reusableInputView]; + _reusableInputView.textInputDelegate = _textInputDelegate; for (NSDictionary* field in configuration[@"fields"]) { NSString* autofillId = autofillIdFromDictionary(field); @@ -1144,8 +1152,9 @@ - (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configura return _reusableInputView; } -- (FlutterTextInputView*)updateAndShowPasswordAutofillViews:(NSArray*)fields - focusedField:(NSDictionary*)focusedField { +- (FlutterTextInputView*)updateAndShowAutofillViews:(NSArray*)fields + focusedField:(NSDictionary*)focusedField + isPasswordRelated:(BOOL)isPassword { FlutterTextInputView* focused = nil; NSString* focusedId = autofillIdFromDictionary(focusedField); NSAssert(focusedId, @"autofillId must not be null for the focused field: %@", focusedField); @@ -1153,7 +1162,7 @@ - (FlutterTextInputView*)updateAndShowPasswordAutofillViews:(NSArray*)fields if (!fields) { // DO NOT push the current autofillable input fields to the context even // if it's password-related, because it is not in an autofill group. - focused = [self getOrCreateAutofillableView:focusedField isPasswordAutofill:YES]; + focused = [self getOrCreateAutofillableView:focusedField isPasswordAutofill:isPassword]; [_autofillContext removeObjectForKey:focusedId]; } @@ -1165,13 +1174,14 @@ - (FlutterTextInputView*)updateAndShowPasswordAutofillViews:(NSArray*)fields BOOL isFocused = [focusedId isEqualToString:autofillId]; if (isFocused) { - focused = [self getOrCreateAutofillableView:field isPasswordAutofill:YES]; + focused = [self getOrCreateAutofillableView:field isPasswordAutofill:isPassword]; } if (hasHints) { // Push the current input field to the context if it has hints. - _autofillContext[autofillId] = - isFocused ? focused : [self getOrCreateAutofillableView:field isPasswordAutofill:YES]; + _autofillContext[autofillId] = isFocused ? focused + : [self getOrCreateAutofillableView:field + isPasswordAutofill:isPassword]; } else { // Mark for deletion; [_autofillContext removeObjectForKey:autofillId]; @@ -1185,8 +1195,8 @@ - (FlutterTextInputView*)updateAndShowPasswordAutofillViews:(NSArray*)fields // Returns a new non-reusable input view (and put it into the view hierarchy), or get the // view from the current autofill context, if an input view with the same autofill id // already exists in the context. -// This is generally used for input fields that are password-related (UIKit tracks these -// views for password autofill so they should not be reused for a differnet type of views). +// This is generally used for input fields that are autofillable (UIKit tracks these veiws +// for autofill purposes so they should not be reused for a different type of views). - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field isPasswordAutofill:(BOOL)needsPasswordAutofill { NSString* autofillId = autofillIdFromDictionary(field); diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m index bb0be615cb540..c10f01908665c 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m @@ -61,8 +61,8 @@ - (void)setClientId:(int)clientId configuration:(NSDictionary*)config { - (void)commitAutofillContextAndVerify { FlutterMethodCall* methodCall = - [FlutterMethodCall methodCallWithMethodName:@"TextInput.AutofillContext.commit" - arguments:nil]; + [FlutterMethodCall methodCallWithMethodName:@"TextInput.finishAutofillContext" + arguments:@YES]; [textInputPlugin handleMethodCall:methodCall result:^(id _Nullable result){ }]; @@ -353,7 +353,7 @@ - (void)testCommitAutofillContext { XCTAssertNotEqual(textInputPlugin.textInputView, nil); [self commitAutofillContextAndVerify]; - XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); + XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView); // Now switch to an input field that does not autofill. [self setClientId:125 configuration:self.mutableTemplateCopy]; From d0dd8e6990656a2f3ec9d5f2cb0ed8acf714385f Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 8 Jul 2020 20:35:49 -0700 Subject: [PATCH 10/14] update --- .../darwin/ios/framework/Source/FlutterTextInputPlugin.mm | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 8ede4ca6a146f..bd9d5a2145c99 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -1063,10 +1063,7 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { [self clearTextInputClient]; result(nil); } else if ([method isEqualToString:@"TextInput.finishAutofillContext"]) { - [self triggerAutofillSave:args]; - result(nil); - } else if ([method isEqualToString:@"TextInput.AutofillContext.clear"]) { - [self triggerAutofillSave:NO]; + [self triggerAutofillSave:[args boolValue]]; result(nil); } else { result(FlutterMethodNotImplemented); From 17e11f5b834e3aad1a80e4736be0b441f1e211f8 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Thu, 9 Jul 2020 12:45:59 -0700 Subject: [PATCH 11/14] review --- .../Source/FlutterTextInputPlugin.mm | 87 +++++++++++++------ .../Source/FlutterTextInputPluginTest.m | 2 +- 2 files changed, 60 insertions(+), 29 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index bd9d5a2145c99..fb118054bc1d7 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -11,6 +11,26 @@ static const char _kTextAffinityDownstream[] = "TextAffinity.downstream"; static const char _kTextAffinityUpstream[] = "TextAffinity.upstream"; +#pragma mark - TextInputConfiguration Field Names +static NSString* const _kSecureTextEntry = @"obscureText"; +static NSString* const _kKeyboardType = @"inputType"; +static NSString* const _kKeyboardAppearance = @"keyboardAppearance"; +static NSString* const _kInputAction = @"inputAction"; + +static NSString* const _kSmartDashesType = @"smartDashesType"; +static NSString* const _kSmartQuotesType = @"smartQuotesType"; + +static NSString* const _kAssociatedAutofillFields = @"fields"; + +// TextInputConfiguration.autofill and sub-field names +static NSString* const _kAutofillProperties = @"autofill"; +static NSString* const _kAutofillId = @"uniqueIdentifier"; +static NSString* const _kAutofillEditingValue = @"editingValue"; +static NSString* const _kAutofillHints = @"hints"; + +static NSString* const _kAutocorrectionType = @"autocorrect"; + +#pragma mark - Static Functions static UIKeyboardType ToUIKeyboardType(NSDictionary* type) { NSString* inputType = type[@"name"]; if ([inputType isEqualToString:@"TextInputType.address"]) @@ -209,15 +229,17 @@ static UITextContentType ToUITextContentType(NSArray* hints) { return hints[0]; } +// Retrieves the autofillId from an input field's configuration. Returns +// nil if the field is nil and the input field is not a password field. static NSString* autofillIdFromDictionary(NSDictionary* dictionary) { - NSDictionary* autofill = dictionary[@"autofill"]; + NSDictionary* autofill = dictionary[_kAutofillProperties]; if (autofill) { - return autofill[@"uniqueIdentifier"]; + return autofill[_kAutofillId]; } // When autofill is nil, the field may still need an autofill id // if the field is for password. - return [dictionary[@"obscureText"] boolValue] ? @"password" : nil; + return [dictionary[_kSecureTextEntry] boolValue] ? @"password" : nil; } // There're 2 types of autofills on native iOS: @@ -243,15 +265,15 @@ typedef NS_ENUM(NSInteger, FlutterAutofillType) { static BOOL isFieldPasswordRelated(NSDictionary* configuration) { if (@available(iOS 10.0, *)) { - BOOL isSecureTextEntry = [configuration[@"obscureText"] boolValue]; + BOOL isSecureTextEntry = [configuration[_kSecureTextEntry] boolValue]; if (isSecureTextEntry) return YES; if (!autofillIdFromDictionary(configuration)) { return NO; } - NSDictionary* autofill = configuration[@"autofill"]; - UITextContentType contentType = ToUITextContentType(autofill[@"hints"]); + NSDictionary* autofill = configuration[_kAutofillProperties]; + UITextContentType contentType = ToUITextContentType(autofill[_kAutofillHints]); if (@available(iOS 11.0, *)) { if ([contentType isEqualToString:UITextContentTypePassword] || @@ -270,7 +292,7 @@ static BOOL isFieldPasswordRelated(NSDictionary* configuration) { } static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { - for (NSDictionary* field in configuration[@"fields"]) { + for (NSDictionary* field in configuration[_kAssociatedAutofillFields]) { if (isFieldPasswordRelated(field)) { return FlutterAutofillTypePassword; } @@ -281,8 +303,8 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { } if (@available(iOS 10.0, *)) { - NSDictionary* autofill = configuration[@"autofill"]; - UITextContentType contentType = ToUITextContentType(autofill[@"hints"]); + NSDictionary* autofill = configuration[_kAutofillProperties]; + UITextContentType contentType = ToUITextContentType(autofill[_kAutofillHints]); return [contentType isEqualToString:@""] ? FlutterAutofillTypeNone : FlutterAutofillTypeRegular; } @@ -426,23 +448,23 @@ - (instancetype)init { } - (void)configureWithDictionary:(NSDictionary*)configuration { - NSDictionary* inputType = configuration[@"inputType"]; - NSString* keyboardAppearance = configuration[@"keyboardAppearance"]; - NSDictionary* autofill = configuration[@"autofill"]; + NSDictionary* inputType = configuration[_kKeyboardType]; + NSString* keyboardAppearance = configuration[_kKeyboardAppearance]; + NSDictionary* autofill = configuration[_kAutofillProperties]; - self.secureTextEntry = [configuration[@"obscureText"] boolValue]; + self.secureTextEntry = [configuration[_kSecureTextEntry] boolValue]; self.keyboardType = ToUIKeyboardType(inputType); self.keyboardType = UIKeyboardTypeNamePhonePad; - self.returnKeyType = ToUIReturnKeyType(configuration[@"inputAction"]); + self.returnKeyType = ToUIReturnKeyType(configuration[_kInputAction]); self.autocapitalizationType = ToUITextAutoCapitalizationType(configuration); if (@available(iOS 11.0, *)) { - NSString* smartDashesType = configuration[@"smartDashesType"]; + NSString* smartDashesType = configuration[_kSmartDashesType]; // This index comes from the SmartDashesType enum in the framework. bool smartDashesIsDisabled = smartDashesType && [smartDashesType isEqualToString:@"0"]; self.smartDashesType = smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes; - NSString* smartQuotesType = configuration[@"smartQuotesType"]; + NSString* smartQuotesType = configuration[_kSmartQuotesType]; // This index comes from the SmartQuotesType enum in the framework. bool smartQuotesIsDisabled = smartQuotesType && [smartQuotesType isEqualToString:@"0"]; self.smartQuotesType = @@ -455,7 +477,7 @@ - (void)configureWithDictionary:(NSDictionary*)configuration { } else { self.keyboardAppearance = UIKeyboardAppearanceDefault; } - NSString* autocorrect = configuration[@"autocorrect"]; + NSString* autocorrect = configuration[_kAutocorrectionType]; self.autocorrectionType = autocorrect && ![autocorrect boolValue] ? UITextAutocorrectionTypeNo : UITextAutocorrectionTypeDefault; @@ -464,12 +486,12 @@ - (void)configureWithDictionary:(NSDictionary*)configuration { if (autofill == nil) { self.textContentType = @""; } else { - self.textContentType = ToUITextContentType(autofill[@"hints"]); - [self setTextInputState:autofill[@"editingValue"]]; + self.textContentType = ToUITextContentType(autofill[_kAutofillHints]); + [self setTextInputState:autofill[_kAutofillEditingValue]]; NSAssert(_autofillId, @"The autofill configuration must contain an autofill id"); - // The input field needs to be visible for the system autofill - // to find it. } + // The input field needs to be visible for the system autofill + // to find it. self.isVisibleToAutofill = autofill || _secureTextEntry; } } @@ -549,6 +571,11 @@ - (BOOL)isVisibleToAutofill { return self.frame.size.width > 0 && self.frame.size.height > 0; } +// An input view is generally ignored by password autofill attempts, if it's +// not the first responder and is zero-sized. For input fields that are in the +// autofill context but do not belong to the current autofill group, setting +// their frames to CGRectZero prevents ios autofill from taking them into +// account. - (void)setIsVisibleToAutofill:(BOOL)isVisibleToAutofill { self.frame = isVisibleToAutofill ? CGRectMake(0, 0, 1, 1) : CGRectZero; } @@ -1007,7 +1034,7 @@ - (BOOL)accessibilityElementsHidden { @end @interface FlutterTextInputPlugin () -@property(nonatomic, retain) FlutterTextInputView* reusableInputView; +@property(nonatomic, strong) FlutterTextInputView* reusableInputView; // The current password-autofillable input fields that have yet to be saved. @property(nonatomic, readonly) @@ -1106,12 +1133,14 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur _activeView = [self updateAndShowReusableInputView:configuration]; break; case FlutterAutofillTypeRegular: + // If the group does not involve password autofill, only install the + // input view that's being focused. _activeView = [self updateAndShowAutofillViews:nil focusedField:configuration isPasswordRelated:NO]; break; case FlutterAutofillTypePassword: - _activeView = [self updateAndShowAutofillViews:configuration[@"fields"] + _activeView = [self updateAndShowAutofillViews:configuration[_kAssociatedAutofillFields] focusedField:configuration isPasswordRelated:YES]; break; @@ -1125,8 +1154,8 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur } // Updates and shows an input field that is not password related and has no autofill -// hints. This method reconfigure an existing instance of input field instead of -// creating a new one. +// hints. This method re-configures and reuses an existing instance of input field +// instead of creating a new one. // Also updates the current autofill context. - (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configuration { // It's possible that the configuration of this non-autofillable input view has @@ -1140,7 +1169,7 @@ - (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configura [self addToKeyWindowIfNeeded:_reusableInputView]; _reusableInputView.textInputDelegate = _textInputDelegate; - for (NSDictionary* field in configuration[@"fields"]) { + for (NSDictionary* field in configuration[_kAssociatedAutofillFields]) { NSString* autofillId = autofillIdFromDictionary(field); if (autofillId && autofillTypeOf(field) == FlutterAutofillTypeNone) { [_autofillContext removeObjectForKey:autofillId]; @@ -1201,17 +1230,19 @@ - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field if (!inputView) { inputView = needsPasswordAutofill ? [FlutterSecureTextInputView alloc] : [FlutterTextInputView alloc]; - inputView = [[inputView init] autorelease]; + inputView = [inputView init]; [self addToKeyWindowIfNeeded:inputView]; } inputView.textInputDelegate = _textInputDelegate; [inputView configureWithDictionary:field]; - return inputView; + return [inputView autorelease]; } // Removes every installed input field, unless it's in the current autofill // context. May remove the active view too if includeActiveView is YES. +// When clearText is YES, the text on the input fields will be set to empty before +// they are removed from the view hierarchy, to avoid autofill save . - (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText { UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; NSAssert(keyWindow != nullptr, diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m index c10f01908665c..0f31558c0677e 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m @@ -18,7 +18,7 @@ - (BOOL)isVisibleToAutofill; @end @interface FlutterTextInputPlugin () -@property(nonatomic, retain) FlutterTextInputView* reusableInputView; +@property(nonatomic, strong) FlutterTextInputView* reusableInputView; @property(nonatomic, assign) FlutterTextInputView* activeView; @property(nonatomic, readonly) NSMutableDictionary* autofillContext; From 93e5dafb2d24c6bd48d85ea4b48d74d30a0c6a47 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Thu, 9 Jul 2020 15:37:35 -0700 Subject: [PATCH 12/14] review --- .../Source/FlutterTextInputPlugin.mm | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index fb118054bc1d7..41d214c1bbaaa 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -12,23 +12,23 @@ static const char _kTextAffinityUpstream[] = "TextAffinity.upstream"; #pragma mark - TextInputConfiguration Field Names -static NSString* const _kSecureTextEntry = @"obscureText"; -static NSString* const _kKeyboardType = @"inputType"; -static NSString* const _kKeyboardAppearance = @"keyboardAppearance"; -static NSString* const _kInputAction = @"inputAction"; +static NSString* const kSecureTextEntry = @"obscureText"; +static NSString* const kKeyboardType = @"inputType"; +static NSString* const kKeyboardAppearance = @"keyboardAppearance"; +static NSString* const kInputAction = @"inputAction"; -static NSString* const _kSmartDashesType = @"smartDashesType"; -static NSString* const _kSmartQuotesType = @"smartQuotesType"; +static NSString* const kSmartDashesType = @"smartDashesType"; +static NSString* const kSmartQuotesType = @"smartQuotesType"; -static NSString* const _kAssociatedAutofillFields = @"fields"; +static NSString* const kAssociatedAutofillFields = @"fields"; // TextInputConfiguration.autofill and sub-field names -static NSString* const _kAutofillProperties = @"autofill"; -static NSString* const _kAutofillId = @"uniqueIdentifier"; -static NSString* const _kAutofillEditingValue = @"editingValue"; -static NSString* const _kAutofillHints = @"hints"; +static NSString* const kAutofillProperties = @"autofill"; +static NSString* const kAutofillId = @"uniqueIdentifier"; +static NSString* const kAutofillEditingValue = @"editingValue"; +static NSString* const kAutofillHints = @"hints"; -static NSString* const _kAutocorrectionType = @"autocorrect"; +static NSString* const kAutocorrectionType = @"autocorrect"; #pragma mark - Static Functions static UIKeyboardType ToUIKeyboardType(NSDictionary* type) { @@ -232,14 +232,14 @@ static UITextContentType ToUITextContentType(NSArray* hints) { // Retrieves the autofillId from an input field's configuration. Returns // nil if the field is nil and the input field is not a password field. static NSString* autofillIdFromDictionary(NSDictionary* dictionary) { - NSDictionary* autofill = dictionary[_kAutofillProperties]; + NSDictionary* autofill = dictionary[kAutofillProperties]; if (autofill) { - return autofill[_kAutofillId]; + return autofill[kAutofillId]; } // When autofill is nil, the field may still need an autofill id // if the field is for password. - return [dictionary[_kSecureTextEntry] boolValue] ? @"password" : nil; + return [dictionary[kSecureTextEntry] boolValue] ? @"password" : nil; } // There're 2 types of autofills on native iOS: @@ -265,15 +265,15 @@ typedef NS_ENUM(NSInteger, FlutterAutofillType) { static BOOL isFieldPasswordRelated(NSDictionary* configuration) { if (@available(iOS 10.0, *)) { - BOOL isSecureTextEntry = [configuration[_kSecureTextEntry] boolValue]; + BOOL isSecureTextEntry = [configuration[kSecureTextEntry] boolValue]; if (isSecureTextEntry) return YES; if (!autofillIdFromDictionary(configuration)) { return NO; } - NSDictionary* autofill = configuration[_kAutofillProperties]; - UITextContentType contentType = ToUITextContentType(autofill[_kAutofillHints]); + NSDictionary* autofill = configuration[kAutofillProperties]; + UITextContentType contentType = ToUITextContentType(autofill[kAutofillHints]); if (@available(iOS 11.0, *)) { if ([contentType isEqualToString:UITextContentTypePassword] || @@ -292,7 +292,7 @@ static BOOL isFieldPasswordRelated(NSDictionary* configuration) { } static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { - for (NSDictionary* field in configuration[_kAssociatedAutofillFields]) { + for (NSDictionary* field in configuration[kAssociatedAutofillFields]) { if (isFieldPasswordRelated(field)) { return FlutterAutofillTypePassword; } @@ -303,8 +303,8 @@ static FlutterAutofillType autofillTypeOf(NSDictionary* configuration) { } if (@available(iOS 10.0, *)) { - NSDictionary* autofill = configuration[_kAutofillProperties]; - UITextContentType contentType = ToUITextContentType(autofill[_kAutofillHints]); + NSDictionary* autofill = configuration[kAutofillProperties]; + UITextContentType contentType = ToUITextContentType(autofill[kAutofillHints]); return [contentType isEqualToString:@""] ? FlutterAutofillTypeNone : FlutterAutofillTypeRegular; } @@ -448,23 +448,23 @@ - (instancetype)init { } - (void)configureWithDictionary:(NSDictionary*)configuration { - NSDictionary* inputType = configuration[_kKeyboardType]; - NSString* keyboardAppearance = configuration[_kKeyboardAppearance]; - NSDictionary* autofill = configuration[_kAutofillProperties]; + NSDictionary* inputType = configuration[kKeyboardType]; + NSString* keyboardAppearance = configuration[kKeyboardAppearance]; + NSDictionary* autofill = configuration[kAutofillProperties]; - self.secureTextEntry = [configuration[_kSecureTextEntry] boolValue]; + self.secureTextEntry = [configuration[kSecureTextEntry] boolValue]; self.keyboardType = ToUIKeyboardType(inputType); self.keyboardType = UIKeyboardTypeNamePhonePad; - self.returnKeyType = ToUIReturnKeyType(configuration[_kInputAction]); + self.returnKeyType = ToUIReturnKeyType(configuration[kInputAction]); self.autocapitalizationType = ToUITextAutoCapitalizationType(configuration); if (@available(iOS 11.0, *)) { - NSString* smartDashesType = configuration[_kSmartDashesType]; + NSString* smartDashesType = configuration[kSmartDashesType]; // This index comes from the SmartDashesType enum in the framework. bool smartDashesIsDisabled = smartDashesType && [smartDashesType isEqualToString:@"0"]; self.smartDashesType = smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes; - NSString* smartQuotesType = configuration[_kSmartQuotesType]; + NSString* smartQuotesType = configuration[kSmartQuotesType]; // This index comes from the SmartQuotesType enum in the framework. bool smartQuotesIsDisabled = smartQuotesType && [smartQuotesType isEqualToString:@"0"]; self.smartQuotesType = @@ -477,7 +477,7 @@ - (void)configureWithDictionary:(NSDictionary*)configuration { } else { self.keyboardAppearance = UIKeyboardAppearanceDefault; } - NSString* autocorrect = configuration[_kAutocorrectionType]; + NSString* autocorrect = configuration[kAutocorrectionType]; self.autocorrectionType = autocorrect && ![autocorrect boolValue] ? UITextAutocorrectionTypeNo : UITextAutocorrectionTypeDefault; @@ -486,8 +486,8 @@ - (void)configureWithDictionary:(NSDictionary*)configuration { if (autofill == nil) { self.textContentType = @""; } else { - self.textContentType = ToUITextContentType(autofill[_kAutofillHints]); - [self setTextInputState:autofill[_kAutofillEditingValue]]; + self.textContentType = ToUITextContentType(autofill[kAutofillHints]); + [self setTextInputState:autofill[kAutofillEditingValue]]; NSAssert(_autofillId, @"The autofill configuration must contain an autofill id"); } // The input field needs to be visible for the system autofill @@ -1140,7 +1140,7 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur isPasswordRelated:NO]; break; case FlutterAutofillTypePassword: - _activeView = [self updateAndShowAutofillViews:configuration[_kAssociatedAutofillFields] + _activeView = [self updateAndShowAutofillViews:configuration[kAssociatedAutofillFields] focusedField:configuration isPasswordRelated:YES]; break; @@ -1169,7 +1169,7 @@ - (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configura [self addToKeyWindowIfNeeded:_reusableInputView]; _reusableInputView.textInputDelegate = _textInputDelegate; - for (NSDictionary* field in configuration[_kAssociatedAutofillFields]) { + for (NSDictionary* field in configuration[kAssociatedAutofillFields]) { NSString* autofillId = autofillIdFromDictionary(field); if (autofillId && autofillTypeOf(field) == FlutterAutofillTypeNone) { [_autofillContext removeObjectForKey:autofillId]; @@ -1230,13 +1230,13 @@ - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field if (!inputView) { inputView = needsPasswordAutofill ? [FlutterSecureTextInputView alloc] : [FlutterTextInputView alloc]; - inputView = [inputView init]; + inputView = [[inputView init] autorelease]; [self addToKeyWindowIfNeeded:inputView]; } inputView.textInputDelegate = _textInputDelegate; [inputView configureWithDictionary:field]; - return [inputView autorelease]; + return inputView; } // Removes every installed input field, unless it's in the current autofill From c5d3e04fc0aa730964728d51a208bb0ab7fe7595 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Mon, 20 Jul 2020 14:14:39 -0700 Subject: [PATCH 13/14] formatting --- .../ios/framework/Source/FlutterTextInputPluginTest.m | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m index eeb80842b586d..21b1d1cf5ae36 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m @@ -226,11 +226,11 @@ - (void)testComposingChangeTriggersUpdateEditingClient { - (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, @@ -238,9 +238,9 @@ - (void)testUpdateEditingClientNegativeSelection { }]; OCMVerify([engine updateEditingClient:0 withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { - return ([state[@"selectionBase"] intValue]) == 0 && - ([state[@"selectionExtent"] intValue] == 0); - }]]); + return ([state[@"selectionBase"] intValue]) == 0 && + ([state[@"selectionExtent"] intValue] == 0); + }]]); } - (void)testAutofillContext { From 10c7bb49f82568eda3bd5acef69cb98fc673cbbe Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Mon, 20 Jul 2020 16:25:57 -0700 Subject: [PATCH 14/14] textfield to be per instance --- .../Source/FlutterTextInputPlugin.mm | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 09e01e5360590..a135c4fcc7f19 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -374,14 +374,21 @@ - (BOOL)isEqualTo:(FlutterTextRange*)other { // currently only support UITextFields, and password saving only supports // UITextFields and UITextViews, as of iOS 13.5. @interface FlutterSecureTextInputView : FlutterTextInputView -@property(class, nonatomic, assign, readonly) UITextField* textField; +@property(nonatomic, strong, readonly) UITextField* textField; @end -@implementation FlutterSecureTextInputView -+ (UITextField*)textField { - static UITextField* _textField = nil; +@implementation FlutterSecureTextInputView { + UITextField* _textField; +} + +- (void)dealloc { + [_textField release]; + [super dealloc]; +} + +- (UITextField*)textField { if (_textField == nil) { - _textField = [[UITextField alloc] init]; + _textField = [[[UITextField alloc] init] autorelease]; } return _textField; } @@ -393,13 +400,13 @@ - (BOOL)isKindOfClass:(Class)aClass { - (NSMethodSignature*)methodSignatureForSelector:(SEL)aSelector { NSMethodSignature* signature = [super methodSignatureForSelector:aSelector]; if (!signature) { - signature = [[FlutterSecureTextInputView textField] methodSignatureForSelector:aSelector]; + signature = [self.textField methodSignatureForSelector:aSelector]; } return signature; } - (void)forwardInvocation:(NSInvocation*)anInvocation { - [anInvocation invokeWithTarget:[FlutterSecureTextInputView textField]]; + [anInvocation invokeWithTarget:self.textField]; } @end